From 073cb6a57749e1c89823844141b8723a64ecac76 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 13 Aug 2026 19:01:16 +0800 Subject: [PATCH 001/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth/JWT-JWKS-CONTRACT.md | 76 ++++++++++++ .../auth/client/AuthorizationSnapshot.java | 1 + .../auth/config/AuthSecurityConfig.java | 12 +- .../auth/config/DomesticJwtConfiguration.java | 22 ++++ .../auth/config/OverseasJwtConfiguration.java | 37 ++++++ .../auth/config/OverseasJwtProperties.java | 10 ++ .../auth/controller/AuthController.java | 14 +++ .../auth/controller/JwksController.java | 31 +++++ .../com/baiyue/auth/service/AuthService.java | 17 +-- .../auth/service/OverseasTokenService.java | 56 +++++++++ auth/src/main/resources/application.yml | 7 ++ .../auth/client/AuthorizationLoaderTest.java | 2 +- ...AuthTokenTtlConfigurationContractTest.java | 3 +- .../config/DualTrackJwtConfigurationTest.java | 29 +++++ .../auth/controller/JwksControllerTest.java | 40 +++++++ .../auth/service/AuthServiceSmallJwtTest.java | 15 +-- .../service/OverseasTokenServiceTest.java | 74 ++++++++++++ .../common/event/IamSecurityEventsV1.java | 18 +++ config/nacos/cn/auth-service.yml | 10 +- deploy/maven/settings-cn.xml | 42 +++++-- deploy/sql/README.md | 1 + deploy/sql/头程数据库统一增量-MySQL8.sql | 57 ++++++++- iam/SECURITY-EVENT-AUDIT.md | 39 +++++++ iam/pom.xml | 1 + .../iam/client/SecurityEventBridgeClient.java | 17 +++ .../com/baiyue/iam/dto/AuthorizationDtos.java | 1 + .../iam/mapper/AuthorizationQueryMapper.java | 3 + .../SecurityEventOutboxRelay.java | 22 ---- .../SecurityEventRepository.java | 25 ---- .../securityevent/SecurityEventService.java | 23 ---- .../service/AuthorizationSnapshotService.java | 3 +- .../com/baiyue/iam/service/MemberService.java | 3 + .../service/PlatformInternalUserService.java | 5 + .../PlatformUserManagementService.java | 4 + .../com/baiyue/iam/service/RbacService.java | 7 +- .../iam/service/SecurityEventOutboxRelay.java | 87 ++++++++++++++ .../service/SecurityEventOutboxService.java | 67 +++++++++++ .../com/baiyue/iam/service/TenantService.java | 3 + .../main/resources/db/lastleg-permissions.sql | 25 ++++ .../resources/db/security-event-outbox.sql | 36 ++++-- .../SecurityEventSqlContractTest.java | 43 +++++++ .../baiyue/iam/service/MemberServiceTest.java | 1 + .../PlatformInternalUserServiceTest.java | 1 + .../PlatformUserManagementServiceTest.java | 1 + .../baiyue/iam/service/RbacServiceTest.java | 1 + .../iam/service/TenantCustomerExportTest.java | 1 + .../baiyue/iam/service/TenantServiceTest.java | 1 + .../service/MerchantBatchDraftService.java | 1 + .../MerchantBatchDraftServiceTest.java | 109 ++++++++++++++++++ pom.xml | 13 +++ 50 files changed, 981 insertions(+), 136 deletions(-) create mode 100644 auth/JWT-JWKS-CONTRACT.md create mode 100644 auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java create mode 100644 auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java create mode 100644 auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java create mode 100644 auth/src/main/java/com/baiyue/auth/controller/JwksController.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java create mode 100644 auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java create mode 100644 iam/SECURITY-EVENT-AUDIT.md create mode 100644 iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventService.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java create mode 100644 iam/src/main/resources/db/lastleg-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java diff --git a/auth/JWT-JWKS-CONTRACT.md b/auth/JWT-JWKS-CONTRACT.md new file mode 100644 index 000000000..d92044357 --- /dev/null +++ b/auth/JWT-JWKS-CONTRACT.md @@ -0,0 +1,76 @@ +# 国内与海外 JWT/JWKS 冻结契约 + +## 双轨边界 + +### 国内现有链路 + +- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。 +- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。 +- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。 +- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。 +- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。 + +### 海外尾程链路 + +- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。 +- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。 +- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。 +- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。 +- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。 + +## 海外签发契约 + +- `issuer`:`auth-service` +- `audience`:`lastleg-api` +- 算法白名单:仅 `RS256` +- Access Token 默认有效期:15 分钟 +- 兑换接口:`POST /api/auth/overseas/token` +- JWKS 路径:`/api/auth/.well-known/jwks.json` +- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json` +- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300` + +海外 Token 的 JSON 声明名称和类型固定如下: + +| 声明 | JSON 类型 | 说明 | +|---|---|---| +| `sub` | string | 用户 ID 的字符串形式 | +| `jti` | string | Token 唯一标识 | +| `iat` | number | 签发时间 | +| `exp` | number | 过期时间 | +| `aud` | string array | 固定包含 `lastleg-api` | +| `userId` | number | 认证用户 ID | +| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 | +| `appCode` | string | `PLATFORM` 或 `MERCHANT` | +| `roles` | string array | 当前角色编码快照 | +| `authorities` | string array | 当前权限编码快照 | +| `permissionVersion` | number | IAM 单调权限版本 | + +海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。 + +## 海外配置 + +```yaml +baiyue: + security: + overseas-jwt: + enabled: ${OVERSEAS_JWT_ENABLED:false} + issuer: ${OVERSEAS_JWT_ISSUER:auth-service} + audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} + rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} + current-kid: ${OVERSEAS_JWT_CURRENT_KID:} + access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} +``` + +只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。 + +## 密钥轮换 + +- JWK Set 只允许 RSA 密钥。 +- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。 +- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。 +- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。 +- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。 + +## IAM 安全事件 + +安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。 diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 4e7361ecf..47bc2000a 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -20,4 +20,5 @@ public class AuthorizationSnapshot { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; } diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java index 7362715cc..c301d1aff 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java @@ -1,6 +1,5 @@ package com.baiyue.auth.config; -import com.nimbusds.jose.jwk.source.ImmutableSecret; import com.baiyue.common.security.JwtAuthenticationFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import java.nio.charset.StandardCharsets; /** * 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。 @@ -31,6 +27,7 @@ public class AuthSecurityConfig { "/api/auth/refresh", "/api/auth/logout", "/api/auth/impersonations/exchange", + "/api/auth/.well-known/jwks.json", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", @@ -40,13 +37,6 @@ public class AuthSecurityConfig { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); } - @Bean - JwtEncoder jwtEncoder(AuthSecurityProperties properties) { - byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); - } - @Bean SecurityFilterChain authSecurityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter, diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java new file mode 100644 index 000000000..acae75153 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java @@ -0,0 +1,22 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import java.nio.charset.StandardCharsets; + +/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */ +@Configuration(proxyBeanMethods = false) +public class DomesticJwtConfiguration { + @Bean("jwtEncoder") + @Primary + JwtEncoder jwtEncoder(AuthSecurityProperties properties) { + byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); + if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); + return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java new file mode 100644 index 000000000..ed7d60faa --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java @@ -0,0 +1,37 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(OverseasJwtProperties.class) +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class OverseasJwtConfiguration { + @Bean("overseasJwtEncoder") + JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) { + try { + JWKSet set = JWKSet.parse(properties.rsaJwkSet()); + RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null; + if (current == null || !current.isPrivate()) { + throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥"); + } + if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) { + throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥"); + } + if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID()) + && jwk instanceof RSAKey rsa && rsa.isPrivate())) { + throw new IllegalStateException("海外非当前密钥只能配置公钥"); + } + return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); + } catch (java.text.ParseException exception) { + throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception); + } + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java new file mode 100644 index 000000000..62e173c7f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java @@ -0,0 +1,10 @@ +package com.baiyue.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** 海外尾程 RS256 短时令牌配置,默认关闭。 */ +@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt") +public record OverseasJwtProperties(boolean enabled, String issuer, String audience, + String rsaJwkSet, String currentKid, Duration accessTokenTtl) { } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index e021efea6..5901b1d0e 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -23,6 +23,8 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos; import com.baiyue.auth.service.AccountManagementService; +import com.baiyue.auth.service.OverseasTokenService; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.access.prepost.PreAuthorize; /** @@ -38,6 +40,18 @@ public class AuthController { private MerchantRegistrationService registrationService; @Resource private AccountManagementService accountManagementService; + @Resource + private ObjectProvider overseasTokens; + + @PostMapping("/overseas/token") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") + public ApiResponse exchangeOverseasToken( + @AuthenticationPrincipal AuthenticatedUser user) { + OverseasTokenService service = overseasTokens.getIfAvailable(); + if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); + return ApiResponse.success(service.exchange(user.userId())); + } @PutMapping("/password") @PreAuthorize("isAuthenticated()") diff --git a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java new file mode 100644 index 000000000..f8fa9487e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -0,0 +1,31 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.config.OverseasJwtProperties; +import com.nimbusds.jose.jwk.JWKSet; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ +@RestController +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class JwksController { + private final OverseasJwtProperties properties; + + public JwksController(OverseasJwtProperties properties) { this.properties = properties; } + + @GetMapping("/api/auth/.well-known/jwks.json") + @SecurityRequirements + @Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") + public ResponseEntity> jwks() throws Exception { + return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) + .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index f9be566f8..4a6783539 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -30,7 +30,6 @@ import java.time.LocalDateTime; import java.util.Base64; import java.util.UUID; import java.time.Duration; -import java.util.List; /** * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。 @@ -109,8 +108,7 @@ public class AuthService { String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); JwtClaimsSet claims = JwtClaimsSet.builder() - .issuer(properties.issuer()).audience(List.of(properties.audience())) - .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) + .issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) .claim("user_id", account.getId()) .claim("username", account.getUsername()) @@ -121,12 +119,6 @@ public class AuthService { .claim("client_type", authorization.getClientType()) .claim("token_version", account.getTokenVersion()) .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())) - .claim("userId", account.getId()) - .claim("tenantId", account.getDefaultTenantId()) - .claim("appCode", account.getDefaultAppCode()) - .claim("roles", authorization.getRoles()) - .claim("authorities", authorization.getPermissions()) - .claim("permissionVersion", account.getTokenVersion()) .build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); @@ -175,17 +167,12 @@ public class AuthService { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15)); String jwtId = UUID.randomUUID().toString(); - JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()) - .audience(List.of(properties.audience())).id(jwtId) + JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId) .issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString()) .claim("user_id", account.getId()).claim("username", account.getUsername()) .claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId()) .claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT") .claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion()) - .claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) - .claim("appCode", "MERCHANT").claim("roles", authorization.getRoles()) - .claim("authorities", authorization.getPermissions()) - .claim("permissionVersion", account.getTokenVersion()) .claim("impersonated", true).claim("impersonation_id", ticket.ticketId()) .claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId()) .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java new file mode 100644 index 000000000..0f7eb256a --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -0,0 +1,56 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ +@Service +@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") +public class OverseasTokenService { + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + @Resource private OverseasJwtProperties properties; + @Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder; + + public OverseasTokenResponse exchange(long authenticatedUserId) { + AuthAccount account = accounts.findById(authenticatedUserId); + if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { + throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); + } + AuthorizationSnapshot authorization = authorizationLoader.load(account); + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); + JwtClaimsSet claims = JwtClaimsSet.builder() + .issuer(properties.issuer()).audience(List.of(properties.audience())) + .subject(account.getId().toString()).id(UUID.randomUUID().toString()) + .issuedAt(issuedAt).expiresAt(expiresAt) + .claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) + .claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles()) + .claim("authorities", authorization.getPermissions()) + .claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) + .build(); + String token = encoder.encode(JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); + return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); + } + + public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { } +} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 126c6e6c3..33fe66410 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -39,6 +39,13 @@ baiyue.security: refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} max-login-failures: ${MAX_LOGIN_FAILURES:5} lock-duration: ${LOGIN_LOCK_DURATION:15m} + overseas-jwt: + enabled: ${OVERSEAS_JWT_ENABLED:false} + issuer: ${OVERSEAS_JWT_ISSUER:auth-service} + audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} + rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} + current-kid: ${OVERSEAS_JWT_CURRENT_KID:} + access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} baiyue.clients: iam: diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java index d0768d74c..7712cde85 100644 --- a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java @@ -72,6 +72,6 @@ class AuthorizationLoaderTest { private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) { return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode, - Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view")); + Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L); } } diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java index 9da820b4c..0b7440f31 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest { Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); assertThat(configuration) - .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}") + .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}") .contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") + .contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}") .doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}"); } } diff --git a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java new file mode 100644 index 000000000..65c871721 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java @@ -0,0 +1,29 @@ +package com.baiyue.auth.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.oauth2.jwt.JwtEncoder; + +import static org.assertj.core.api.Assertions.assertThat; + +class DualTrackJwtConfigurationTest { + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class)) + .withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties( + "domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api", + java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5, + java.time.Duration.ofMinutes(15))) + .withPropertyValues( + "baiyue.security.overseas-jwt.enabled=false"); + + @Test + void startsDomesticEncoderWithoutRsaConfiguration() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("jwtEncoder"); + assertThat(context).doesNotHaveBean("overseasJwtEncoder"); + assertThat(context).hasSingleBean(JwtEncoder.class); + }); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java new file mode 100644 index 000000000..53a673c72 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -0,0 +1,40 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.config.OverseasJwtProperties; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.util.Base64URL; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwksControllerTest { + @Test + void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception { + RSAKey current = key("current", true); + RSAKey previous = key("previous", false); + String json = new JWKSet(List.of(current, previous)).toString(false); + var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json, + "current", Duration.ofMinutes(15)); + + var body = new JwksController(properties).jwks().getBody(); + String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); + + assertThat(published).contains("current", "previous", "\"n\"", "\"e\"") + .doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\""); + } + + private RSAKey key(String kid, boolean privatePart) { + RSAKey.Builder builder = new RSAKey.Builder( + Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17))) + .keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256); + if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753))); + return builder.build(); + } +} 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 517d0f459..997ed697c 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -47,7 +47,7 @@ class AuthServiceSmallJwtTest { } @Test - void signsCrossRegionClaimsAndStoresSession() { + void signsDomesticHmacClaimsAndStoresSession() { AuthAccount account = new AuthAccount(); account.setId(42L); account.setUsername("operator"); @@ -59,7 +59,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list")); + "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 7L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -74,15 +74,10 @@ class AuthServiceSmallJwtTest { verify(jwtEncoder).encode(jwt.capture()); assertThat(jwt.getValue().getClaims().getClaims()) .containsEntry("user_id", 42L) - .containsEntry("userId", 42L) - .containsEntry("tenantId", 0L) - .containsEntry("appCode", "PLATFORM") - .containsEntry("roles", Set.of("PLATFORM_ADMIN")) - .containsEntry("authorities", Set.of("platform:order:list")) - .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) - .doesNotContainKeys("permissions", "scope"); - assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); + .doesNotContainKeys("userId", "tenantId", "appCode", "roles", "authorities", + "permissionVersion", "permissions", "scope"); + assertThat(jwt.getValue().getClaims().getAudience()).isNull(); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java new file mode 100644 index 000000000..f2a0fe86b --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -0,0 +1,74 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.TestPropertySource; + +import java.time.Duration; +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.when; + +@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) +@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true") +class OverseasTokenServiceTest { + @MockBean private AuthAccountRepository accounts; + @MockBean private AuthorizationLoader authorizationLoader; + @MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder; + @Resource private OverseasTokenService service; + + @Configuration(proxyBeanMethods = false) + static class Config { + @Bean OverseasJwtProperties properties() { + return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid", + Duration.ofMinutes(15)); + } + } + + @Test + void signsShortRs256TokenWithCrossRegionClaims() { + AuthAccount account = new AuthAccount(); + account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L); + account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB", + Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + when(accounts.findById(42L)).thenReturn(account); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(encoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("overseas-token"); + + var response = service.exchange(42L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); + verify(encoder).encode(captor.capture()); + var parameters = captor.getValue(); + assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); + assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); + assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); + assertThat(parameters.getClaims().getClaims()) + .containsEntry("userId", 42L).containsEntry("tenantId", 100L) + .containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L) + .containsEntry("roles", Set.of("MERCHANT_ADMIN")) + .containsEntry("authorities", Set.of("merchant:order:list")) + .doesNotContainKeys("user_id", "tenant_id", "app_code"); + assertThat(response.expiresIn()).isEqualTo(900); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java new file mode 100644 index 000000000..3794f37ef --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java @@ -0,0 +1,18 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** IAM 向海外服务发布的 V1 安全投影事件负载。 */ +public final class IamSecurityEventsV1 { + public static final int VERSION = 1; + + private IamSecurityEventsV1() { } + + public record UserStatusChanged(long authUserId, long tenantId, String appCode, + String status, long permissionVersion, Instant changedAt) { } + + public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } + + public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, + long permissionVersion, String reason, Instant changedAt) { } +} diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml index fd913df2e..6c2d38bdd 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -6,6 +6,12 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver baiyue: security: - audience: ${JWT_AUDIENCE:lastleg-api} - access-token-ttl: ${ACCESS_TOKEN_TTL:15m} + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} + overseas-jwt: + enabled: ${OVERSEAS_JWT_ENABLED:false} + issuer: ${OVERSEAS_JWT_ISSUER:auth-service} + audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} + rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} + current-kid: ${OVERSEAS_JWT_CURRENT_KID:} + access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/deploy/maven/settings-cn.xml b/deploy/maven/settings-cn.xml index 96d2d9207..d3516a762 100644 --- a/deploy/maven/settings-cn.xml +++ b/deploy/maven/settings-cn.xml @@ -4,36 +4,54 @@ xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"> - aliyun-public - 阿里云 Maven 公共仓库 - https://maven.aliyun.com/repository/public - central + baiyue-nexus-public + 柏悦 Nexus Maven 聚合仓库 + http://8.163.30.128:18081/repository/maven-public/ + external:* + + + baiyue-nexus-public + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-releases + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-snapshots + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + - 国内仓库 + 柏悦 Nexus - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - 国内仓库 + 柏悦 Nexus diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0f4dc2457..f5caabb04 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -11,6 +11,7 @@ - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 +- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 后续追加增量必须满足: diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index acf8dced0..26be8bd62 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14890,6 +14890,61 @@ DELIMITER ; CALL assert_finance_permissions(); DROP PROCEDURE IF EXISTS assert_finance_permissions; +-- IAM 跨区安全事件可靠发送箱。来源:iam/src/main/resources/db/security-event-outbox.sql +USE baiyue_iam; +CREATE TABLE IF NOT EXISTS iam_security_subject_version ( + auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (auth_user_id) +) ENGINE=InnoDB COMMENT='安全主体权限版本'; + +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态事件版本'; + +CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型', + aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', + payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', + attempt_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_owner VARCHAR(128) NULL, + lease_until DATETIME(3) NULL, + last_error VARCHAR(1000) NULL, + sent_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (event_id), + KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) +) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱'; + +DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; +DELIMITER $$ +CREATE PROCEDURE assert_iam_security_event_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name IN ('iam_security_subject_version','iam_security_tenant_version','iam_security_event_outbox')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件表不完整'; + END IF; + IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema='baiyue_iam' AND table_name='iam_security_event_outbox' + AND index_name IN ('PRIMARY','idx_security_outbox_publish','idx_security_outbox_aggregate')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件索引不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_iam_security_event_schema(); +DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; + USE baiyue_firstleg; -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox @@ -14897,4 +14952,4 @@ ALTER TABLE fl_inbound_outbox CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; + '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md new file mode 100644 index 000000000..0f0a49f51 --- /dev/null +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -0,0 +1,39 @@ +# IAM 跨区安全事件契约 + +## 边界 + +IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 Outbox 在同一数据库事务内提交;尾程按 `eventId` 幂等消费,并按信封 `aggregateVersion` 拒绝旧事件。事件不包含密码、令牌、联系方式、角色全集或权限全集。 + +## V1 事件 + +三个事件均使用标准 `EventEnvelope`,`eventVersion=1`、`sourceRegion=CN`、`targetRegion=OVERSEAS`。 + +1. `IamUserStatusChanged` + - 聚合:`aggregateType=USER`、`aggregateId=authUserId` + - payload:`authUserId`、`tenantId`、`appCode`、`status`、`permissionVersion`、`changedAt` + - status:`ACTIVE`、`DISABLED` +2. `IamTenantStatusChanged` + - 聚合:`aggregateType=TENANT`、`aggregateId=tenantId` + - payload:`tenantId`、`status`、`changedAt` + - status:`ACTIVE`、`DISABLED` + - `aggregateVersion` 来自 `iam_security_tenant_version.status_version`,每次状态变化单调递增 +3. `IamPermissionVersionChanged` + - 聚合:`aggregateType=AUTHORIZATION`、`aggregateId=authUserId` + - payload:`authUserId`、`tenantId`、`appCode`、`permissionVersion`、`reason`、`changedAt` + - `permissionVersion` 来自 `iam_security_subject_version.permission_version`,按账号单调递增 + +## 当前接入 + +- 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 +- 客户启停写 `IamTenantStatusChanged`。 +- 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 + +## 尾程权限 + +`lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: + +- `platform:lastleg:order:list` +- `platform:lastleg:order:detail` +- `platform:lastleg:order:assign` +- `platform:lastleg:order:transfer` +- `platform:lastleg:order:merge-pallet` diff --git a/iam/pom.xml b/iam/pom.xml index 2c35a4bc9..7f583178c 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml iam-service + com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java new file mode 100644 index 000000000..4adf81539 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -0,0 +1,17 @@ +package com.baiyue.iam.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient") +public interface SecurityEventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 1ea405654..6867ebf88 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -39,5 +39,6 @@ public final class AuthorizationDtos { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; } } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 1a65a7d07..f38fff466 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -62,4 +62,7 @@ public interface AuthorizationQueryMapper { @Param("tenantId") long tenantId, @Param("membershipId") long membershipId, @Param("appCode") String appCode); + + @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") + long findPermissionVersion(@Param("userId") long userId); } diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java deleted file mode 100644 index cf540d3c5..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.iam.securityevent; - -import jakarta.annotation.Resource; -import org.apache.rocketmq.spring.core.RocketMQTemplate; -import org.apache.rocketmq.spring.support.RocketMQHeaders; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import java.util.UUID; - -@Component -@ConditionalOnProperty(name="baiyue.security-event.publisher-enabled",havingValue="true") -public class SecurityEventOutboxRelay { - @Resource private SecurityEventRepository repository; - @Resource private RocketMQTemplate rocketMQ; - @Value("${baiyue.security-event.topic:baiyue-security-event-v1}") private String topic; - private final String owner=UUID.randomUUID().toString(); - @Scheduled(fixedDelayString="${baiyue.security-event.poll-interval-ms:5000}") - public void relay(){for(String id:repository.pendingIds(50)){if(!repository.claim(id,owner))continue;var row=repository.find(id);try{rocketMQ.syncSend(topic+":"+row.eventType(),MessageBuilder.withPayload(row.payload()).setHeader(RocketMQHeaders.KEYS,row.eventId()).setHeader("eventId",row.eventId()).build(),3000);repository.sent(id,owner);}catch(RuntimeException ex){repository.failed(id,owner,row.attempts(),ex.getMessage());}}} -} diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java deleted file mode 100644 index 930ae9f69..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.iam.securityevent; - -import jakarta.annotation.Resource; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; -import java.util.List; - -@Repository -public class SecurityEventRepository { - @Resource private JdbcTemplate jdbc; - public long incrementPermissionVersion(long authUserId) { - jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); - return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", Long.class, authUserId); - } - public void append(String id,String type,String aggregateType,String aggregateId,String payload) { - jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json,status,attempt_count,next_retry_at) VALUES (?,?,?,?,CAST(? AS JSON),'PENDING',0,CURRENT_TIMESTAMP(3))", id,type,aggregateType,aggregateId,payload); - } - public List pendingIds(int limit) { return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE (status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)) ORDER BY created_at LIMIT ?", String.class,limit); } - public boolean claim(String id,String owner) { return jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),attempt_count=attempt_count+1 WHERE event_id=? AND ((status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)))",owner,id)==1; } - public Row find(String id) { return jdbc.queryForObject("SELECT event_id,event_type,payload_json,attempt_count FROM iam_security_event_outbox WHERE event_id=?",(rs,n)->new Row(rs.getString(1),rs.getString(2),rs.getString(3),rs.getInt(4)),id); } - public void sent(String id,String owner) { jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE event_id=? AND lease_owner=?",id,owner); } - public void failed(String id,String owner,int attempts,String error) { jdbc.update("UPDATE iam_security_event_outbox SET status=IF(attempt_count>=8,'DEAD','PENDING'),next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),lease_owner=NULL,lease_until=NULL,last_error=? WHERE event_id=? AND lease_owner=?",Math.min(900,5*(1< fields){String id=UUID.randomUUID().toString();Map payload=new LinkedHashMap<>();payload.put("eventId",id);payload.put("eventType",type);payload.put("schemaVersion",1);payload.put("occurredAt",Instant.now().toString());payload.putAll(fields);try{repository.append(id,type,aggregateType,Long.toString(aggregateId),json.writeValueAsString(payload));}catch(JsonProcessingException ex){throw new IllegalStateException("安全事件序列化失败",ex);}} -} diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index c90ffec80..1592dbcf0 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -46,7 +46,8 @@ public class AuthorizationSnapshotService { Set permissions = normalizedSet(mapper.findPermissions( request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), - appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions)); + appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), + mapper.findPermissionVersion(request.getUserId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 627998a7c..bc1c9b896 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -40,6 +40,8 @@ public class MemberService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -159,6 +161,7 @@ public class MemberService { membership.setStatus(request.status()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); + securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 64b8df116..f0285949a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -38,6 +38,8 @@ public class PlatformInternalUserService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -138,6 +140,7 @@ public class PlatformInternalUserService { user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); @@ -167,6 +170,8 @@ public class PlatformInternalUserService { repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) { revokeSessions(user.getAuthUserId()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "MEMBER_ROLES_CHANGED"); } AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index d35d25a17..73a8e7fe9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -43,6 +43,8 @@ public class PlatformUserManagementService { private IamRoleMapper roleMapper; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -185,6 +187,8 @@ public class PlatformUserManagementService { user.setStatus(request.status()); members.saveUser(user); members.save(membership); + securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), request.status()); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 23c76a052..2645ca088 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -33,6 +33,7 @@ public class RbacService { @Resource private RbacRepository repository; @Resource private MemberRepository memberRepository; @Resource private AuthAccountClient authClient; + @Resource private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -232,6 +233,7 @@ public class RbacService { var user = memberRepository.findUser(membership.getUserId()); if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable(); revokeSessions(List.of(user.getAuthUserId())); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED"); } } @@ -249,7 +251,10 @@ public class RbacService { } } repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds()); - revokeAssignedSessions(tenantId, request.roleId()); + List affected = repository.findAssignedAuthUserIds(tenantId, request.roleId()); + revokeSessions(affected); + affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode, + "ROLE_PERMISSIONS_CHANGED")); } private void revokeAssignedSessions(long tenantId, Long roleId) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java new file mode 100644 index 000000000..32b9bd9e4 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -0,0 +1,87 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.iam.client.SecurityEventBridgeClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */ +@Component +@Slf4j +public class SecurityEventOutboxRelay { + @Resource private JdbcTemplate jdbc; + @Resource private ObjectMapper objectMapper; + @Resource private SecurityEventBridgeClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}") + public void relay() { + recoverExpiredLeases(); + for (String eventId : candidates()) deliver(eventId); + } + + @Transactional + public void deliver(String eventId) { + LocalDateTime now = LocalDateTime.now(); + int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?," + + "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, eventId, now); + if (claimed != 1) return; + String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox " + + "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId); + try { + EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); + EventEnvelopeValidator.requireValid(envelope); + var response = client.receive(internalToken, envelope); + if (response == null || !response.successful() || response.data() == null + || !eventId.equals(response.data().eventId())) { + throw new IllegalStateException("事件桥未确认 IAM 安全事件"); + } + jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL," + + "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?", + LocalDateTime.now(), LocalDateTime.now(), eventId, workerId); + } catch (Exception exception) { + fail(eventId, exception.getMessage()); + log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage()); + } + } + + private List candidates() { + return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' " + + "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); + } + + private void recoverExpiredLeases() { + jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL," + + "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until= maxAttempts ? "DEAD" : "PENDING"; + long delay = Math.min(1800, 5L << Math.min(next, 8)); + jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?," + + "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?", + status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId); + } + + private String truncate(String value) { + String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); + return text.length() > 1000 ? text.substring(0, 1000) : text; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java new file mode 100644 index 000000000..d9372a2e2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -0,0 +1,67 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */ +@Service +public class SecurityEventOutboxService { + @Resource private JdbcTemplate jdbc; + @Resource private ObjectMapper objectMapper; + + public void userStatus(long authUserId, long tenantId, String appCode, String status) { + long version = bump(authUserId); + write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version, + new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now())); + } + + public void tenantStatus(long tenantId, String status) { + jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); + long version = jdbc.queryForObject( + "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, + new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now())); + } + + public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { + long version = bump(authUserId); + write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version, + new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now())); + } + + private long bump(long authUserId) { + jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) " + + "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId); + return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", + Long.class, authUserId); + } + + private void write(String type, String aggregateType, String aggregateId, long tenantId, + long aggregateVersion, Object value) { + JsonNode payload = objectMapper.valueToTree(value); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION, + Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType, + aggregateId, aggregateVersion, null, EventIdentifiers.newUuid(), null, null, payload, hash).requireValid(); + try { + jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId, + objectMapper.writeValueAsString(envelope)); + } catch (Exception exception) { + throw new IllegalStateException("IAM 安全事件写入失败", exception); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index e84f24792..939e7a406 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -66,6 +66,8 @@ public class TenantService { private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @Resource private TenantAdministratorProvisioningService administratorProvisioning; + @Resource + private SecurityEventOutboxService securityEvents; @Transactional @BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT", @@ -380,6 +382,7 @@ public class TenantService { } tenant.setStatus(request.status()); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenantId, request.status()); return response(tenant); } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql new file mode 100644 index 000000000..98348b610 --- /dev/null +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -0,0 +1,25 @@ +USE baiyue_iam; + +-- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。 +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), + (5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +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 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 + AND permission.permission_code IN ( + 'platform:lastleg:order:list', 'platform:lastleg:order:detail', + 'platform:lastleg:order:assign', 'platform:lastleg:order:transfer', + 'platform:lastleg:order:merge-pallet'); diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql index e9afe00e5..995b26206 100644 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -2,20 +2,34 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_security_subject_version ( auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', - permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,角色或权限变化时单调递增', + permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (auth_user_id) ) ENGINE=InnoDB COMMENT='安全主体权限版本'; -CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( - event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', event_type VARCHAR(64) NOT NULL, - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、ACCOUNT、AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL, - payload_json JSON NOT NULL COMMENT '不含密码、令牌和权限全集的安全事件', - status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', - attempt_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, - sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), +CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( + tenant_id BIGINT NOT NULL COMMENT '客户ID', + status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - PRIMARY KEY (event_id), KEY idx_security_outbox_publish (status, next_retry_at, lease_until, created_at), - KEY idx_security_outbox_aggregate (aggregate_type, aggregate_id, created_at) + PRIMARY KEY (tenant_id) +) ENGINE=InnoDB COMMENT='客户状态事件版本'; + +CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( + event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型', + aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', + payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', + attempt_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_owner VARCHAR(128) NULL, + lease_until DATETIME(3) NULL, + last_error VARCHAR(1000) NULL, + sent_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (event_id), + KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), + KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) ) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱'; diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java new file mode 100644 index 000000000..55171dea6 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java @@ -0,0 +1,43 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventSqlContractTest { + @Test + void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception { + String sql = new ClassPathResource("db/security-event-outbox.sql") + .getContentAsString(StandardCharsets.UTF_8); + assertThat(sql) + .contains("iam_security_subject_version") + .contains("permission_version BIGINT NOT NULL DEFAULT 0") + .contains("iam_security_tenant_version") + .contains("status_version BIGINT NOT NULL DEFAULT 0") + .contains("iam_security_event_outbox") + .contains("payload_json JSON NOT NULL") + .contains("idx_security_outbox_publish") + .contains("PENDING", "SENDING", "SENT", "DEAD"); + } + + @Test + void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception { + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path migration = Files.exists(direct) + ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize(); + String sql = Files.readString(migration, StandardCharsets.UTF_8); + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version") + .contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version") + .contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox") + .contains("idx_security_outbox_publish", "idx_security_outbox_aggregate") + .contains("CALL assert_iam_security_event_schema();"); + assertThat(sql.indexOf("CALL assert_iam_security_event_schema();")) + .isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index beefc4f31..265237dc1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -33,6 +33,7 @@ class MemberServiceTest { @MockBean private TenantRepository tenantRepository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MemberService service; private IamMembership member; private IamUser user; diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index bb1ed8d8f..adaccd1b1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -30,6 +30,7 @@ class PlatformInternalUserServiceTest { @MockBean private MemberRepository repository; @MockBean private IamRoleMapper roleMapper; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private PlatformInternalUserService service; private IamMembership membership; private IamUser user; 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 c79c9b22f..42df71a34 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -31,6 +31,7 @@ class PlatformUserManagementServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private PlatformUserManagementService service; private IamRole platformRole; private IamMembership membership; diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 228deafe6..50e3083d5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -39,6 +39,7 @@ class RbacServiceTest { @MockBean private RbacRepository repository; @MockBean private MemberRepository memberRepository; @MockBean private AuthAccountClient authClient; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private RbacService service; @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 3c805ff66..6ff844faa 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -28,6 +28,7 @@ class TenantCustomerExportTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private TenantAdministratorProvisioningService administratorProvisioning; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantService service; @Test void returnsUnmaskedCustomerRowsAndAdvancingCursor() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b274ddec..fd402ab75 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -53,6 +53,7 @@ class TenantServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient authClient; + @MockBean private SecurityEventOutboxService securityEvents; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @SpyBean private TenantAdministratorProvisioningService provisioning; diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java index 56b4e7f34..dfa6bbd1a 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java @@ -23,6 +23,7 @@ public class MerchantBatchDraftService { public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey, List items) { if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key"); + if (batchKey.trim().length() > 128) throw new BizException("Idempotency-Key 长度不能超过128"); if (items == null || items.isEmpty()) throw new BizException("批量草稿不能为空"); if (items.size() > MAX_ITEMS) throw new BizException("批量草稿最多 50 条"); List results = new ArrayList<>(items.size()); diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java new file mode 100644 index 000000000..eb5a52742 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java @@ -0,0 +1,109 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.MerchantBatchDraftDtos; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderEntity; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@SpringJUnitConfig(MerchantBatchDraftService.class) +class MerchantBatchDraftServiceTest { + @MockBean private MerchantBatchDraftItemService itemService; + @Resource private MerchantBatchDraftService service; + + @Test + void createsEachRowAndReturnsOrderReferences() { + doAnswer(invocation -> order(101L, "FL101", "RK-101")) + .doAnswer(invocation -> order(102L, "FL102", "RK-102")) + .when(itemService).create(anyLong(), anyLong(), any(), anyString()); + + var response = service.create(10L, 20L, "batch-key", List.of( + item("row-1"), item("row-2"))); + + assertThat(response.total()).isEqualTo(2); + assertThat(response.succeeded()).isEqualTo(2); + assertThat(response.failed()).isZero(); + assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::inboundCode) + .containsExactly("RK-101", "RK-102"); + assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::index) + .containsExactly(0, 1); + } + + @Test + void keepsSuccessfulRowsWhenAnotherRowFails() { + doAnswer(invocation -> order(101L, "FL101", "RK-101")) + .doThrow(new BizException("SKU不能为空")) + .when(itemService).create(anyLong(), anyLong(), any(), anyString()); + + var response = service.create(10L, 20L, "batch-key", List.of( + item("row-1"), item("row-2"))); + + assertThat(response.succeeded()).isEqualTo(1); + assertThat(response.failed()).isEqualTo(1); + assertThat(response.items().get(0).success()).isTrue(); + assertThat(response.items().get(1).success()).isFalse(); + assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空"); + } + + @Test + void rejectsDuplicateRowWithoutCreatingItTwice() { + doAnswer(invocation -> order(101L, "FL101", "RK-101")) + .when(itemService).create(anyLong(), anyLong(), any(), anyString()); + + var response = service.create(10L, 20L, "batch-key", List.of( + item("same-row"), item("same-row"))); + + assertThat(response.succeeded()).isEqualTo(1); + assertThat(response.failed()).isEqualTo(1); + assertThat(response.items().get(1).errorCode()).isEqualTo("A0409"); + verify(itemService).create(anyLong(), anyLong(), any(), anyString()); + } + + @Test + void derivesStablePerRowIdempotencyKeys() { + String first = MerchantBatchDraftService.derivedKey(" batch-key ", "row-1"); + String retry = MerchantBatchDraftService.derivedKey("batch-key", "row-1"); + String anotherRow = MerchantBatchDraftService.derivedKey("batch-key", "row-2"); + + assertThat(first).isEqualTo(retry).startsWith("batch-").hasSize(70); + assertThat(anotherRow).isNotEqualTo(first); + } + + @Test + void rejectsInvalidBatchBeforeCreatingRows() { + assertThatThrownBy(() -> service.create(10L, 20L, " ", List.of(item("row-1")))) + .isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(10L, 20L, "x".repeat(129), List.of(item("row-1")))) + .isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(10L, 20L, "batch-key", List.of())) + .isInstanceOf(BizException.class); + verify(itemService, never()).create(anyLong(), anyLong(), any(), anyString()); + } + + private MerchantBatchDraftDtos.Item item(String rowId) { + return new MerchantBatchDraftDtos.Item(rowId, mock(OrderRequests.SaveRequest.class)); + } + + private OrderEntity order(long id, String orderNo, String inboundCode) { + OrderEntity order = new OrderEntity(); + order.setId(id); + order.setOrderNo(orderNo); + order.setInboundCode(inboundCode); + return order; + } +} diff --git a/pom.xml b/pom.xml index c66bdf057..9da4d3cd5 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,19 @@ 柏悦头程平台 柏悦悦物流国内头程业务微服务平台 + + + baiyue-nexus-releases + 柏悦 Nexus 正式制品库 + http://8.163.30.128:18081/repository/maven-releases/ + + + baiyue-nexus-snapshots + 柏悦 Nexus 快照制品库 + http://8.163.30.128:18081/repository/maven-snapshots/ + + + platform-bom common-core From af646c6f1ff7dba8d5701a52f4eeab26780fe790 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 13 Aug 2026 19:49:19 +0800 Subject: [PATCH 002/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/auth-service.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml index 6c2d38bdd..891768268 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -9,7 +9,7 @@ baiyue: access-token-ttl: ${ACCESS_TOKEN_TTL:24m} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:false} + enabled: ${OVERSEAS_JWT_ENABLED:true} issuer: ${OVERSEAS_JWT_ISSUER:auth-service} audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} From 0192380e81160b8fcb196ad985ae0bcccdaa9130 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 14 Aug 2026 15:15:43 +0800 Subject: [PATCH 003/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/EventPayloadCanonicalizer.java | 34 ++++++++++++++++++- .../event/EventPayloadCanonicalizerTest.java | 29 ++++++++++++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 2 ++ .../service/EventInboxService.java | 4 ++- .../firstleg/client/EventBridgeClient.java | 2 +- firstleg/src/main/resources/application.yml | 2 ++ .../resources/db/phase2-inbound-schema.sql | 2 +- ...se3-loading-completed-outbox-migration.sql | 2 ++ 8 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java index 57d173010..6e86f8374 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java @@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer { if (payload == null || payload.isNull()) { throw new IllegalArgumentException("事件负载不能为空"); } + return write(sort(payload)); + } + + /** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */ + public static byte[] canonicalizeLegacy(JsonNode payload) { + if (payload == null || payload.isNull()) { + throw new IllegalArgumentException("事件负载不能为空"); + } + return write(sortLegacy(payload)); + } + + private static byte[] write(JsonNode payload) { try { - return OBJECT_MAPPER.writeValueAsBytes(sort(payload)); + return OBJECT_MAPPER.writeValueAsBytes(payload); } catch (JsonProcessingException exception) { throw new IllegalArgumentException("事件负载无法规范化", exception); } @@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer { node.forEach(child -> result.add(sort(child))); return result; } + if (node.isNumber()) { + return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros()); + } + return node; + } + + private static JsonNode sortLegacy(JsonNode node) { + if (node.isObject()) { + var names = new java.util.ArrayList(); + node.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + var result = OBJECT_MAPPER.createObjectNode(); + names.forEach(name -> result.set(name, sortLegacy(node.get(name)))); + return result; + } + if (node.isArray()) { + var result = OBJECT_MAPPER.createArrayNode(); + node.forEach(child -> result.add(sortLegacy(child))); + return result; + } return node; } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java new file mode 100644 index 000000000..7d0f2938b --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java @@ -0,0 +1,29 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class EventPayloadCanonicalizerTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception { + var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}"); + var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized)); + assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized))); + } + + @Test + void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception { + var negativeZero = json.readTree("{\"value\":-0.000}"); + var zero = json.readTree("{\"value\":0}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero)); + assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero))); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b79ee14be..4d1f2a252 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14959,6 +14959,8 @@ USE baiyue_firstleg; -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +ALTER TABLE fl_inbound_outbox + MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8b68a2789..47042bb03 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -35,7 +35,9 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash()); + boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash()); + if (!currentHash && !legacyHash) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java index 491896aea..f48074f63 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; /** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 60af1f819..81cab7574 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -28,6 +28,8 @@ baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + lastleg: + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index bb9bc4393..b80cf4024 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -733,7 +733,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', + payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法', publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', diff --git a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql index 4b5514187..3190364e2 100644 --- a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql +++ b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql @@ -1,2 +1,4 @@ -- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +-- MySQL JSON 会重写 0.3150 等数值词法,导致入库前计算的 payloadHash 失效。 +ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; From 6b077c20db87c1320ac94be8b136b402734694b4 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 09:28:40 +0800 Subject: [PATCH 004/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV1.java | 46 +++++++++- .../FirstLegLoadingPlanCompletedV1Test.java | 21 +++++ .../mapper/LoadingCompletedEventMapper.java | 32 ++++++- .../LoadingCompletedOutboxService.java | 84 +++++++++++++++++-- .../LoadingCompletedOutboxServiceTest.java | 32 +++++++ 5 files changed, 206 insertions(+), 9 deletions(-) diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java index d396416d8..1b4978c53 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -16,7 +16,24 @@ public record FirstLegLoadingPlanCompletedV1( String destinationWarehouseCode, String destinationWarehouseName, int packageCount, Integer productQuantity, Integer palletCount, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, - List items) { + List items, String customerId, String orderType, List pallets) { + public Order { + items = items == null ? List.of() : items; + orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType; + pallets = pallets == null ? List.of() : pallets; + } + public Order(String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items) { + this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId, + inboundCode, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, packageCount, productQuantity, palletCount, + grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of()); + } } public record Item( @@ -27,6 +44,31 @@ public record FirstLegLoadingPlanCompletedV1( BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaTrackingNo, - String shipmentNo, String shippingMark) { + String shipmentNo, String shippingMark, String palletNo, List cartonMarks) { + public Item { + cartonMarks = cartonMarks == null ? List.of() : cartonMarks; + } + public Item(String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark) { + this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount, + productQuantity, palletCount, grossWeightKg, netWeightKg, + dimensionalWeightKg, volumeCbm, destinationWarehouseId, + destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo, + shipmentNo, shippingMark, null, List.of()); + } } + + /** 一键代发入仓单的托盘冻结快照。 */ + public record PalletSnapshot(String palletNo, List skuPackages) {} + + /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + public record SkuPackageSnapshot(String skuCode, String productName, + int packageCount, int productQuantity, + List cartonMarks) {} } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java index 2c9fb34cb..3b5b72757 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -26,4 +26,25 @@ class FirstLegLoadingPlanCompletedV1Test { assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload); assertThat(json).contains("2088000000000000003", "sourceInboundDetailId"); } + + @Test + void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception { + var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002")); + var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot( + "SKU-2", "产品2", 1, 5, List.of("BOX-003")); + var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200", + "RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE, + BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP", + List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)), + new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2)))); + var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null, + "3", "海外仓", List.of(order)); + + String json = mapper.writeValueAsString(payload); + FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId"); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 86735b3a3..d2f943b58 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -36,6 +36,27 @@ public interface LoadingCompletedEventMapper { """) List rows(@Param("planId") long planId); + /** 读取一键代发托盘及其箱唛快照;普通订单不会使用该结果。 */ + @Select(""" + SELECT i.account_id, i.inbound_id AS source_inbound_id, + a.pallet_code, a.allocation_no, a.picked_package_count AS allocated_package_count, + i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.product_name_zh, i.package_count, i.product_quantity, + label.box_code + FROM fl_loading_plan_item i + JOIN fl_inbound_pallet_allocation a + ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id + AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') + AND a.picked_package_count > 0 + LEFT JOIN fl_inbound_carton_label label + ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id + AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' + WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + ORDER BY i.account_id, i.inbound_id, a.allocation_no, + i.inbound_detail_id, label.carton_index, label.id + """) + List palletRows(@Param("planId") long planId); + @Getter @Setter class Row { private Long loadingPlanId; private String planNo; private Long containerBookingId; @@ -49,5 +70,14 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; + private String businessType; + } + + @Getter @Setter + class PalletRow { + private Long accountId; private Long sourceInboundId; private String palletCode; + private Integer allocationNo; private Integer allocatedPackageCount; + private Long sourceInboundDetailId; private String skuCode; private String productNameZh; + private Integer packageCount; private Integer productQuantity; private String boxCode; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 396341a2b..b913c1084 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -14,6 +14,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,6 +30,7 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; /** 在装柜完成事务内按客户写跨区业务 Outbox。 */ @Service @@ -41,17 +43,20 @@ public class LoadingCompletedOutboxService { public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) - .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows))); + .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, + palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList()))); } - private EventEnvelope envelope(long accountId, List rows) { + private EventEnvelope envelope(long accountId, List rows, List palletRows) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() - .map(this::order).toList(); + .map(value -> order(value, palletRows.stream() + .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList(); var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); @@ -65,7 +70,7 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV1.Order order(List values) { + private FirstLegLoadingPlanCompletedV1.Order order(List values, List palletRows) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -75,14 +80,81 @@ public class LoadingCompletedOutboxService { row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark())) .toList(); + String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(first.getBusinessType()) + ? palletSnapshots(sorted, palletRows) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { + throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); + } return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()), string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), - sumNullableInt(sorted, Row::getProductQuantity), sumNullableInt(sorted, Row::getPalletCount), - sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items); + sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType) + ? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), + sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); } + private List palletSnapshots(List values, List source) { + if (source.isEmpty()) return List.of(); + Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( + p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(), + LinkedHashMap::new, java.util.stream.Collectors.toList())); + int loadedPackages = byPallet.values().stream().map(list -> list.get(0)).map(PalletRow::getAllocatedPackageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); + if (loadedPackages != sumInt(values, Row::getPackageCount)) { + throw new BizException(ResultCode.CONFLICT, "一键代发实装箱数与托盘已拣箱数不一致"); + } + Map details = new LinkedHashMap<>(); + values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).forEach(row -> + details.putIfAbsent(row.getSourceInboundDetailId(), new DetailSnapshot(row.getSourceInboundDetailId(), + row.getSkuCode(), row.getProductNameZh(), row.getPackageCount(), row.getProductQuantity(), new ArrayList<>()))); + source.stream().filter(item -> item.getBoxCode() != null).forEach(item -> { + DetailSnapshot detail = details.get(item.getSourceInboundDetailId()); + if (detail != null && !detail.marks().contains(item.getBoxCode())) detail.marks().add(item.getBoxCode()); + }); + Map remainingPackages = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, DetailSnapshot::packageCount, (a,b)->a, LinkedHashMap::new)); + Map remainingMarks = details.values().stream().collect(java.util.stream.Collectors.toMap( + DetailSnapshot::detailId, ignored -> 0, (a,b)->a, LinkedHashMap::new)); + List result = new ArrayList<>(); + for (List palletRows : byPallet.values()) { + PalletRow first = palletRows.get(0); + int capacity = first.getAllocatedPackageCount() == null ? 0 : first.getAllocatedPackageCount(); + List sku = new ArrayList<>(); + for (DetailSnapshot detail : details.values()) { + int remaining = remainingPackages.getOrDefault(detail.detailId(), 0); + if (capacity <= 0 || remaining <= 0) continue; + int count = Math.min(capacity, remaining); + int totalQuantity = detail.productQuantity() == null ? count + : (int) Math.round((double) detail.productQuantity() * count / Math.max(1, detail.packageCount())); + int markStart = remainingMarks.getOrDefault(detail.detailId(), 0); + int markEnd = Math.min(detail.marks().size(), markStart + count); + List marks = new ArrayList<>(detail.marks().subList(markStart, markEnd)); + if (marks.size() != count) throw new BizException(ResultCode.CONFLICT, "一键代发箱唛数量与托盘箱数不一致"); + sku.add(new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(detail.skuCode(), + detail.productName(), count, totalQuantity, marks)); + remainingPackages.put(detail.detailId(), remaining - count); + remainingMarks.put(detail.detailId(), markEnd); + capacity -= count; + } + if (!sku.isEmpty()) result.add(new FirstLegLoadingPlanCompletedV1.PalletSnapshot( + first.getPalletCode() == null ? "PALLET-" + first.getAllocationNo() : first.getPalletCode(), sku)); + } + if (remainingPackages.values().stream().mapToInt(Integer::intValue).sum() != 0) { + throw new BizException(ResultCode.CONFLICT, "一键代发托盘未覆盖全部实装货物明细"); + } + return result; + } + + private static boolean dropship(String value) { + return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value) + || value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP")); + } + + private record DetailSnapshot(Long detailId, String skuCode, String productName, + Integer packageCount, Integer productQuantity, List marks) {} + private void insert(EventEnvelope envelope) { InboundOutboxEntity value = new InboundOutboxEntity(); value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId())); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index a513d6b4c..0f3116491 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +49,29 @@ class LoadingCompletedOutboxServiceTest { assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); } + @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { + Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP"); + first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); + Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP"); + second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + when(snapshots.palletRows(90L)).thenReturn(List.of( + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-001"), + pallet(11,100,1,"PLT-01",2,101,"SKU-A",2,20,"BOX-002"), + pallet(11,100,2,"PLT-02",1,102,"SKU-B",1,5,"BOX-003"))); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(event.payload().at("/orders/0/customerId").asText()).isEqualTo("11"); + assertThat(event.payload().at("/orders/0/pallets")).hasSize(2); + assertThat(event.payload().at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); + assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); + } + private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); @@ -58,4 +82,12 @@ class LoadingCompletedOutboxServiceTest { r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } + + private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, + long detail,String sku,int packages,int quantity,String boxCode) { + PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); + row.setAllocationNo(allocation); row.setPalletCode(palletNo); row.setAllocatedPackageCount(allocated); + row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品"); + row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row; + } } From 000fd10b6acfe0f5b8f75163516f813cfaa2fe32 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 19 Aug 2026 14:04:16 +0800 Subject: [PATCH 005/231] fix(sql): complete IAM security event comments --- deploy/sql/头程数据库统一增量-MySQL8.sql | 22 +++++++++---------- .../resources/db/security-event-outbox.sql | 22 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 34990fa30..378011e92 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15533,14 +15533,14 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_security_subject_version ( auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (auth_user_id) ) ENGINE=InnoDB COMMENT='安全主体权限版本'; CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( tenant_id BIGINT NOT NULL COMMENT '客户ID', status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (tenant_id) ) ENGINE=InnoDB COMMENT='客户状态事件版本'; @@ -15550,15 +15550,15 @@ CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', - attempt_count INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, - lease_until DATETIME(3) NULL, - last_error VARCHAR(1000) NULL, - sent_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约到期时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次错误', + sent_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 (event_id), KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql index 995b26206..ec4693cdb 100644 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -3,14 +3,14 @@ USE baiyue_iam; CREATE TABLE IF NOT EXISTS iam_security_subject_version ( auth_user_id BIGINT NOT NULL COMMENT '认证账号ID', permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (auth_user_id) ) ENGINE=InnoDB COMMENT='安全主体权限版本'; CREATE TABLE IF NOT EXISTS iam_security_tenant_version ( tenant_id BIGINT NOT NULL COMMENT '客户ID', status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (tenant_id) ) ENGINE=InnoDB COMMENT='客户状态事件版本'; @@ -20,15 +20,15 @@ CREATE TABLE IF NOT EXISTS iam_security_event_outbox ( aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识', payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD', - attempt_count INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, - lease_until DATETIME(3) NULL, - last_error VARCHAR(1000) NULL, - sent_at DATETIME(3) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约到期时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次错误', + sent_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 (event_id), KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) From 6acb6178da76c1ece340f0f7cd9f72cf6a3de56f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 16:24:02 +0800 Subject: [PATCH 006/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/overseas/README.md | 5 + config/nacos/overseas/audit-service.yml | 6 + config/nacos/overseas/baiyue-common.yml | 62 + .../nacos/overseas/event-bridge-service.yml | 20 + config/nacos/overseas/file-service.yml | 13 + config/nacos/overseas/gateway-overseas.yml | 3 + config/nacos/overseas/integration-service.yml | 12 + config/nacos/overseas/lastleg-service.yml | 6 + config/nacos/overseas/print-service.yml | 6 + .../overseas/warehouse-overseas-service.yml | 6 + event-bridge-service/AGENTS.md | 41 +- .../eventbridge/EventBridgeApplication.java | 5 + .../config/EventDispatcherProperties.java | 34 + .../controller/EventBridgeController.java | 25 +- .../domain/BridgeDeliveryAttempt.java | 24 + .../eventbridge/domain/BridgeInbox.java | 8 +- .../eventbridge/dto/EventReplayRequest.java | 4 + .../mapper/BridgeDeliveryAttemptMapper.java | 9 + .../eventbridge/mapper/BridgeInboxMapper.java | 52 + .../security/InternalServiceTokenGuard.java | 7 +- .../service/EventInboxService.java | 30 +- .../service/EventReplayService.java | 27 + .../service/HttpLastlegEventClient.java | 55 + .../service/LastlegEventClient.java | 7 + .../service/MybatisInboxStore.java | 5 +- .../service/OverseasEventDispatcher.java | 88 + .../src/main/resources/application-dev.yml | 6 - .../src/main/resources/application-test.yml | 6 - .../src/main/resources/application.yml | 38 +- .../src/main/resources/db/event-bridge.sql | 4 +- .../InternalServiceTokenGuardTest.java | 26 +- .../service/EventInboxServiceTest.java | 128 +- .../service/EventReplayServiceTest.java | 36 + .../service/OverseasEventDispatcherTest.java | 94 + gateway-overseas/AGENTS.md | 30 + gateway-overseas/pom.xml | 69 + .../BaiyueGatewayOverseasApplication.java | 14 + .../config/AppScopeAuthorizationManager.java | 32 + .../gateway/config/AudienceJwtValidator.java | 23 + .../gateway/config/GatewaySecurityConfig.java | 84 + .../config/GatewaySecurityProperties.java | 27 + .../config/RequiredClaimsJwtValidator.java | 26 + .../filter/TrustedIdentityHeaderFilter.java | 73 + .../main/resources/application-overseas.yml | 69 + .../src/main/resources/application.yml | 67 + .../AppScopeAuthorizationManagerTest.java | 55 + .../config/AudienceJwtValidatorTest.java | 33 + .../RequiredClaimsJwtValidatorTest.java | 36 + lastleg-service/AGENTS.md | 72 + lastleg-service/pom.xml | 23 + .../lastleg/BaiyueLastlegApplication.java | 14 + .../controller/LastlegHealthController.java | 34 + .../controller/LastlegOrderController.java | 113 + .../delivery/DeliveryMapperConfiguration.java | 9 + .../DeliveryTransferController.java | 74 + .../delivery/dto/DeliveryRequests.java | 23 + .../delivery/dto/DeliveryResponses.java | 27 + .../delivery/entity/DeliveryOrder.java | 30 + .../delivery/entity/OutboundOrder.java | 28 + .../delivery/entity/OutboundPalletItem.java | 27 + .../delivery/mapper/DeliveryOrderMapper.java | 17 + .../delivery/mapper/OutboundOrderMapper.java | 17 + .../mapper/OutboundPalletItemMapper.java | 14 + .../service/DeliveryTransferService.java | 213 + .../discard/DiscardMapperConfiguration.java | 9 + .../controller/CargoDiscardController.java | 84 + .../discard/dto/CargoDiscardRequests.java | 30 + .../discard/dto/CargoDiscardResponses.java | 30 + .../entity/CargoDiscardApplication.java | 34 + .../discard/entity/CargoDiscardCarton.java | 20 + .../discard/entity/CargoDiscardItem.java | 25 + .../entity/CargoDiscardStatusHistory.java | 21 + .../mapper/CargoDiscardApplicationMapper.java | 19 + .../mapper/CargoDiscardCartonMapper.java | 16 + .../mapper/CargoDiscardInventoryMapper.java | 69 + .../mapper/CargoDiscardItemMapper.java | 16 + .../CargoDiscardStatusHistoryMapper.java | 16 + .../model/CargoDiscardCandidateRow.java | 20 + ...argoDiscardInventoryAdjustmentService.java | 98 + .../discard/service/CargoDiscardService.java | 303 ++ .../dispatch/DispatchMapperConfiguration.java | 9 + .../controller/DispatchController.java | 134 + .../dispatch/dto/DispatchRequests.java | 35 + .../dispatch/dto/DispatchResponse.java | 22 + .../lastleg/dispatch/entity/DispatchItem.java | 38 + .../dispatch/entity/DispatchOrder.java | 40 + .../dispatch/entity/DispatchPalletLock.java | 29 + .../mapper/DispatchCandidateMapper.java | 78 + .../dispatch/mapper/DispatchItemMapper.java | 25 + .../dispatch/mapper/DispatchOrderMapper.java | 16 + .../mapper/DispatchPalletLockMapper.java | 15 + .../model/DispatchPalletCandidate.java | 37 + .../pda/DispatchPdaMapperConfiguration.java | 8 + .../pda/controller/DispatchPdaController.java | 69 + .../dispatch/pda/dto/DispatchPdaRequests.java | 26 + .../pda/dto/DispatchPdaResponses.java | 27 + .../pda/entity/DispatchPdaOperation.java | 23 + .../dispatch/pda/entity/DispatchPdaPick.java | 23 + .../pda/entity/DispatchPdaPickOrder.java | 22 + .../dispatch/pda/entity/DispatchPdaTask.java | 25 + .../mapper/DispatchPdaOperationMapper.java | 11 + .../pda/mapper/DispatchPdaPickMapper.java | 14 + .../mapper/DispatchPdaPickOrderMapper.java | 31 + .../pda/mapper/DispatchPdaTaskMapper.java | 14 + .../service/DispatchPdaWorkflowService.java | 277 ++ .../DispatchPickOrderAttributionService.java | 105 + .../dispatch/service/DispatchService.java | 261 ++ .../dropship/DropshipMapperConfiguration.java | 9 + .../controller/DropshipOrderController.java | 23 + .../dropship/dto/DropshipRequests.java | 15 + .../dropship/entity/DropshipCarton.java | 23 + .../dropship/entity/DropshipCartonLedger.java | 24 + .../dropship/entity/DropshipOrderProfile.java | 22 + .../dropship/entity/DropshipSkuInventory.java | 19 + .../mapper/DropshipCartonLedgerMapper.java | 8 + .../dropship/mapper/DropshipCartonMapper.java | 15 + .../mapper/DropshipOrderProfileMapper.java | 9 + .../mapper/DropshipSkuInventoryMapper.java | 13 + .../service/DropshipOrderService.java | 184 + .../dto/LastlegOrderDetailResponse.java | 27 + .../lastleg/dto/LastlegOrderRequests.java | 55 + .../lastleg/dto/LastlegOrderSummary.java | 51 + .../com/baiyue/lastleg/dto/package-info.java | 5 + .../lastleg/entity/LastlegEventInbox.java | 3 + .../baiyue/lastleg/entity/LastlegOrder.java | 64 + .../lastleg/entity/LastlegOrderDetail.java | 3 + .../entity/LastlegOrderStatusHistory.java | 3 + .../baiyue/lastleg/entity/package-info.java | 5 + .../InboundPlanMapperConfiguration.java | 9 + .../controller/InboundPlanController.java | 74 + .../dto/BatchFbaAppointmentRequest.java | 19 + .../dto/ConvertInboundPlanRequest.java | 9 + .../dto/FbaAppointmentResponse.java | 23 + .../inboundplan/dto/InboundPlanResponse.java | 37 + .../entity/InboundFbaAppointment.java | 25 + .../entity/InboundFbaAppointmentItem.java | 22 + .../inboundplan/entity/InboundPlan.java | 25 + .../inboundplan/entity/InboundPlanLine.java | 25 + .../inboundplan/entity/InboundPlanPallet.java | 31 + .../entity/InboundPlanPalletAllocation.java | 25 + .../InboundFbaAppointmentItemMapper.java | 9 + .../mapper/InboundFbaAppointmentMapper.java | 9 + .../InboundFbaAppointmentQueryMapper.java | 22 + .../mapper/InboundPlanLineMapper.java | 9 + .../inboundplan/mapper/InboundPlanMapper.java | 13 + .../mapper/InboundPlanOrderMapper.java | 50 + .../InboundPlanPalletAllocationMapper.java | 9 + .../mapper/InboundPlanPalletMapper.java | 9 + .../model/FbaAppointmentLineSnapshot.java | 10 + .../model/FbaAppointmentStatus.java | 8 + .../model/SourceOrderSnapshot.java | 30 + .../InboundPalletAssignmentController.java | 43 + .../pda/controller/InboundPdaController.java | 107 + .../inboundplan/pda/dto/PdaRequests.java | 19 + .../inboundplan/pda/dto/PdaResponses.java | 26 + .../pda/entity/InboundPalletMerge.java | 32 + .../pda/entity/InboundPdaOperation.java | 24 + .../pda/entity/InboundPdaTask.java | 27 + .../pda/entity/InboundStockReceipt.java | 34 + .../gateway/HttpWarehouseResourceGateway.java | 89 + .../pda/gateway/WarehouseResourceGateway.java | 11 + .../pda/mapper/InboundPalletMergeMapper.java | 9 + .../pda/mapper/InboundPdaOperationMapper.java | 13 + .../pda/mapper/InboundPdaTaskMapper.java | 16 + .../pda/mapper/InboundStockReceiptMapper.java | 14 + .../pda/repository/PdaWorkflowRepository.java | 79 + .../service/InboundPdaWorkflowService.java | 532 +++ .../service/InboundStockReceiptService.java | 193 + .../service/InboundFbaAppointmentService.java | 180 + .../service/InboundPlanService.java | 243 ++ .../inboundplan/service/PalletAllocator.java | 129 + .../InventoryMapperConfiguration.java | 9 + .../InventoryCommandMapperConfiguration.java | 9 + .../InventoryCommandController.java | 106 + .../command/dto/InventoryCommandRequests.java | 60 + .../command/dto/InventoryCommandResponse.java | 19 + .../mapper/InventoryCommandMapper.java | 22 + .../service/InventoryCommandService.java | 453 +++ .../inventory/entity/InventoryBalance.java | 37 + .../inventory/entity/InventoryLedger.java | 50 + .../inventory/entity/InventoryOperation.java | 25 + .../inventory/entity/InventoryPosition.java | 34 + .../mapper/InventoryBalanceMapper.java | 52 + .../mapper/InventoryLedgerMapper.java | 33 + .../mapper/InventoryOperationMapper.java | 16 + .../mapper/InventoryPositionMapper.java | 27 + .../inventory/model/InventorySummary.java | 15 + .../InventoryQueryMapperConfiguration.java | 9 + .../controller/InventoryQueryController.java | 112 + .../query/dto/InventoryBalanceView.java | 28 + .../query/dto/InventoryDashboardResponse.java | 15 + .../query/dto/InventoryLedgerView.java | 28 + .../query/dto/InventoryPositionView.java | 28 + .../query/dto/InventoryQueryRequests.java | 53 + .../query/mapper/InventoryQueryMapper.java | 119 + .../model/InventoryAggregateProjection.java | 15 + .../query/service/InventoryQueryService.java | 182 + .../mapper/LastlegEventInboxMapper.java | 3 + .../mapper/LastlegOrderDetailMapper.java | 3 + .../lastleg/mapper/LastlegOrderMapper.java | 38 + .../LastlegOrderStatusHistoryMapper.java | 3 + .../baiyue/lastleg/mapper/package-info.java | 5 + .../ProcurementMapperConfiguration.java | 9 + ...MerchantOverseasProcurementController.java | 56 + ...PlatformOverseasProcurementController.java | 96 + .../dto/OverseasProcurementRequests.java | 63 + .../dto/OverseasProcurementResponses.java | 34 + .../entity/OverseasProcurementOrder.java | 48 + .../entity/OverseasProcurementOutbound.java | 24 + .../OverseasProcurementStatusHistory.java | 21 + .../OverseasProcurementOrderMapper.java | 25 + .../OverseasProcurementOutboundMapper.java | 16 + ...verseasProcurementStatusHistoryMapper.java | 16 + .../service/OverseasProcurementService.java | 293 ++ .../relabel/RelabelMapperConfiguration.java | 9 + .../RelabelApplicationController.java | 53 + .../lastleg/relabel/dto/RelabelRequests.java | 54 + .../lastleg/relabel/dto/RelabelResponses.java | 22 + .../relabel/entity/RelabelApplication.java | 32 + .../lastleg/relabel/entity/RelabelItem.java | 31 + .../relabel/entity/RelabelStatusHistory.java | 21 + .../mapper/RelabelApplicationMapper.java | 19 + .../relabel/mapper/RelabelItemMapper.java | 9 + .../mapper/RelabelStatusHistoryMapper.java | 9 + .../service/RelabelApplicationService.java | 264 ++ .../lastleg/repository/package-info.java | 5 + .../security/InternalServiceTokenGuard.java | 20 + .../service/LastlegOrderNoGenerator.java | 35 + .../lastleg/service/LastlegOrderService.java | 202 + .../service/LoadingCompletedConsumer.java | 203 + .../baiyue/lastleg/service/package-info.java | 5 + .../transfer/TransferMapperConfiguration.java | 4 + .../WarehouseTransferController.java | 93 + .../dto/WarehouseTransferRequests.java | 76 + .../dto/WarehouseTransferResponses.java | 21 + .../WarehouseTransferInventoryAllocation.java | 30 + .../entity/WarehouseTransferItem.java | 20 + .../entity/WarehouseTransferOrder.java | 43 + .../entity/WarehouseTransferReceipt.java | 27 + .../entity/WarehouseTransferSourceOrder.java | 20 + .../mapper/InboundSnapshotMapper.java | 10 + ...ouseTransferInventoryAllocationMapper.java | 21 + .../mapper/WarehouseTransferItemMapper.java | 5 + .../mapper/WarehouseTransferOrderMapper.java | 14 + .../WarehouseTransferReceiptMapper.java | 16 + .../WarehouseTransferSourceOrderMapper.java | 5 + .../service/WarehouseTransferService.java | 536 +++ .../trucking/TruckingMapperConfiguration.java | 9 + .../MerchantUsTruckOrderController.java | 62 + .../PlatformUsTruckOrderController.java | 83 + .../trucking/dto/UsTruckOrderRequests.java | 89 + .../trucking/dto/UsTruckOrderResponses.java | 28 + .../lastleg/trucking/entity/UsTruckOrder.java | 46 + .../trucking/entity/UsTruckOrderPallet.java | 25 + .../entity/UsTruckOrderStatusHistory.java | 21 + .../trucking/mapper/UsTruckOrderMapper.java | 19 + .../mapper/UsTruckOrderPalletMapper.java | 15 + .../UsTruckOrderStatusHistoryMapper.java | 16 + .../trucking/service/UsTruckOrderService.java | 278 ++ .../src/main/resources/application.yml | 44 + .../src/main/resources/db/lastleg.sql | 1139 ++++++ .../LastlegOrderControllerTest.java | 54 + .../delivery/DeliverySchemaContractTest.java | 58 + .../service/DeliveryTransferServiceTest.java | 297 ++ .../CargoDiscardMysqlAcceptanceTest.java | 164 + .../CargoDiscardSchemaContractTest.java | 22 + .../CargoDiscardControllerTest.java | 50 + ...DiscardInventoryAdjustmentServiceTest.java | 108 + .../service/CargoDiscardServiceTest.java | 180 + .../dispatch/DispatchSchemaContractTest.java | 33 + .../DispatchPdaWorkflowServiceTest.java | 121 + ...spatchPickOrderAttributionServiceTest.java | 102 + .../dispatch/service/DispatchServiceTest.java | 112 + .../dropship/DropshipSchemaContractTest.java | 35 + .../service/DropshipOrderServiceTest.java | 148 + .../InboundPlanSchemaContractTest.java | 32 + .../InboundPdaWorkflowServiceTest.java | 374 ++ .../InboundStockReceiptServiceTest.java | 214 + .../InboundFbaAppointmentServiceTest.java | 137 + .../service/InboundPlanServiceTest.java | 143 + .../service/PalletAllocatorTest.java | 57 + .../InventorySchemaContractTest.java | 121 + .../command/InventoryCommandServiceTest.java | 165 + .../InventoryCommandControllerTest.java | 72 + .../InventoryQueryControllerTest.java | 58 + .../service/InventoryQueryServiceTest.java | 131 + ...rseasProcurementInventoryBoundaryTest.java | 25 + ...OverseasProcurementSchemaContractTest.java | 21 + .../OverseasProcurementControllerTest.java | 68 + .../OverseasProcurementServiceTest.java | 103 + .../lastleg/relabel/RelabelContractTest.java | 40 + .../RelabelApplicationServiceTest.java | 259 ++ .../SchemaChineseCommentContractTest.java | 140 + .../InternalServiceTokenGuardTest.java | 12 + .../service/LastlegOrderNoGeneratorTest.java | 31 + .../service/LastlegOrderServiceTest.java | 144 + .../service/LoadingCompletedConsumerTest.java | 13 + .../WarehouseTransferSchemaContractTest.java | 25 + .../service/WarehouseTransferServiceTest.java | 371 ++ .../UsTruckOrderSchemaContractTest.java | 25 + .../UsTruckOrderControllerTest.java | 57 + .../service/UsTruckOrderServiceTest.java | 142 + pom.xml | 3 + scripts/db/incremental.sql | 3553 +++++++++++++++++ warehouse-overseas-service/AGENTS.md | 45 + warehouse-overseas-service/pom.xml | 75 + .../BaiyueWarehouseOverseasApplication.java | 14 + .../WarehouseDatabaseGuardConfiguration.java | 27 + .../InternalWarehousePalletController.java | 25 + ...ternalWarehouseResourceLockController.java | 70 + .../MerchantWarehouseController.java | 67 + .../MerchantWarehouseResourceController.java | 109 + .../PlatformWarehouseController.java | 166 + .../PlatformWarehouseResourceController.java | 131 + .../controller/WarehouseHealthController.java | 26 + .../warehouse/dto/DropshipPalletRequests.java | 9 + .../warehouse/dto/LocationRequests.java | 39 + .../baiyue/warehouse/dto/PalletRequests.java | 45 + .../dto/WarehouseDetailResponse.java | 19 + .../dto/WarehousePalletScrapResponse.java | 4 + .../warehouse/dto/WarehouseRequests.java | 117 + .../dto/WarehouseResourceLockRequests.java | 25 + .../dto/WarehouseResourceLockResponse.java | 25 + .../entity/ServiceCapabilityEntity.java | 57 + .../entity/WarehouseAddressEntity.java | 77 + .../warehouse/entity/WarehouseEntity.java | 71 + .../entity/WarehouseLocationEntity.java | 26 + .../entity/WarehousePalletBindingEntity.java | 19 + .../entity/WarehousePalletEntity.java | 27 + .../entity/WarehouseResourceLockEntity.java | 29 + .../mapper/ServiceCapabilityMapper.java | 16 + .../mapper/WarehouseAddressMapper.java | 16 + .../mapper/WarehouseLocationMapper.java | 22 + .../warehouse/mapper/WarehouseMapper.java | 6 + .../mapper/WarehousePalletBindingMapper.java | 13 + .../mapper/WarehousePalletMapper.java | 7 + .../mapper/WarehouseResourceLockMapper.java | 100 + .../repository/WarehouseRepository.java | 112 + .../WarehouseResourceLockRepository.java | 90 + .../WarehouseResourceRepository.java | 158 + .../security/InternalServiceTokenGuard.java | 26 + .../service/WarehouseApplicationService.java | 176 + .../service/WarehouseResourceLockService.java | 158 + .../service/WarehouseResourceService.java | 282 ++ .../src/main/resources/application.yml | 45 + .../src/main/resources/db/warehouse.sql | 259 ++ ...rehouseDatabaseGuardConfigurationTest.java | 29 + .../WarehouseResourceControllerTest.java | 69 + .../SchemaChineseCommentContractTest.java | 98 + .../WarehouseResourceLockSchemaTest.java | 24 + .../InternalServiceTokenGuardTest.java | 32 + .../WarehouseApplicationServiceTest.java | 59 + .../WarehouseResourceLockServiceTest.java | 263 ++ .../service/WarehouseResourceServiceTest.java | 260 ++ 354 files changed, 25000 insertions(+), 198 deletions(-) create mode 100644 config/nacos/overseas/README.md create mode 100644 config/nacos/overseas/audit-service.yml create mode 100644 config/nacos/overseas/baiyue-common.yml create mode 100644 config/nacos/overseas/event-bridge-service.yml create mode 100644 config/nacos/overseas/file-service.yml create mode 100644 config/nacos/overseas/gateway-overseas.yml create mode 100644 config/nacos/overseas/integration-service.yml create mode 100644 config/nacos/overseas/lastleg-service.yml create mode 100644 config/nacos/overseas/print-service.yml create mode 100644 config/nacos/overseas/warehouse-overseas-service.yml create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java delete mode 100644 event-bridge-service/src/main/resources/application-dev.yml delete mode 100644 event-bridge-service/src/main/resources/application-test.yml create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java create mode 100644 gateway-overseas/AGENTS.md create mode 100644 gateway-overseas/pom.xml create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java create mode 100644 gateway-overseas/src/main/resources/application-overseas.yml create mode 100644 gateway-overseas/src/main/resources/application.yml create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java create mode 100644 lastleg-service/AGENTS.md create mode 100644 lastleg-service/pom.xml create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java create mode 100644 lastleg-service/src/main/resources/application.yml create mode 100644 lastleg-service/src/main/resources/db/lastleg.sql create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java create mode 100644 scripts/db/incremental.sql create mode 100644 warehouse-overseas-service/AGENTS.md create mode 100644 warehouse-overseas-service/pom.xml create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java create mode 100644 warehouse-overseas-service/src/main/resources/application.yml create mode 100644 warehouse-overseas-service/src/main/resources/db/warehouse.sql create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md new file mode 100644 index 000000000..95d89c508 --- /dev/null +++ b/config/nacos/overseas/README.md @@ -0,0 +1,5 @@ +# 海外 Nacos 配置 + +将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。 + +海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml new file mode 100644 index 000000000..1ac49f251 --- /dev/null +++ b/config/nacos/overseas/audit-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${AUDIT_DB_URL} + username: ${AUDIT_DB_USERNAME} + password: ${AUDIT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml new file mode 100644 index 000000000..423373321 --- /dev/null +++ b/config/nacos/overseas/baiyue-common.yml @@ -0,0 +1,62 @@ +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + security: + 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} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} + openapi.enabled: ${SWAGGER_ENABLED:false} + audit.enabled: true +spring: + jackson: + default-property-inclusion: non_null + deserialization: + fail-on-unknown-properties: false + serialization: + write-dates-as-timestamps: false + data: + redis: + host: ${REDIS_HOST:redis-overseas.internal} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + timeout: ${REDIS_TIMEOUT:3s} + cloud: + nacos: + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} + openfeign: + circuitbreaker.enabled: true + client.config.default: + connect-timeout: 3000 + read-timeout: 5000 +server: + shutdown: graceful +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes.enabled: true + show-details: never +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + call-setters-on-nulls: true + global-config: + banner: false + db-config: + id-type: assign_id + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml new file mode 100644 index 000000000..9d911281c --- /dev/null +++ b/config/nacos/overseas/event-bridge-service.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: ${EVENT_BRIDGE_DB_URL} + username: ${EVENT_BRIDGE_DB_USERNAME} + password: ${EVENT_BRIDGE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + region: ${REGION_CODE:OVERSEAS} + deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + event-dispatcher: + consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/config/nacos/overseas/file-service.yml b/config/nacos/overseas/file-service.yml new file mode 100644 index 000000000..971474618 --- /dev/null +++ b/config/nacos/overseas/file-service.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: ${FILE_DB_URL} + username: ${FILE_DB_USERNAME} + password: ${FILE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +file: + storage: + provider: ${FILE_STORAGE_PROVIDER:s3} + endpoint: ${FILE_STORAGE_ENDPOINT:} + bucket: ${FILE_STORAGE_BUCKET:} + access-key: ${FILE_STORAGE_ACCESS_KEY:} + secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml new file mode 100644 index 000000000..e16f76e10 --- /dev/null +++ b/config/nacos/overseas/gateway-overseas.yml @@ -0,0 +1,3 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/integration-service.yml b/config/nacos/overseas/integration-service.yml new file mode 100644 index 000000000..25617fae1 --- /dev/null +++ b/config/nacos/overseas/integration-service.yml @@ -0,0 +1,12 @@ +baiyue: + integration: + baidu-ocr: + enabled: ${BAIDU_OCR_ENABLED:false} + amazon-sp-api: + enabled: ${AMAZON_SP_API_ENABLED:true} + client-id: ${AMAZON_SP_API_CLIENT_ID:} + client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} + refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} + aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} + aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} + region: ${AMAZON_SP_API_REGION:} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml new file mode 100644 index 000000000..1b88f047a --- /dev/null +++ b/config/nacos/overseas/lastleg-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${LASTLEG_DB_URL} + username: ${LASTLEG_DB_USERNAME} + password: ${LASTLEG_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml new file mode 100644 index 000000000..613433e3f --- /dev/null +++ b/config/nacos/overseas/print-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${PRINT_DB_URL} + username: ${PRINT_DB_USERNAME} + password: ${PRINT_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml new file mode 100644 index 000000000..a05d57b33 --- /dev/null +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -0,0 +1,6 @@ +spring: + datasource: + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 59c29eeec..7e2e6672b 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -2,33 +2,32 @@ ## 职责边界 -- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。 -- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。 -- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。 +- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。 +- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。 +- 事件结构和 Payload 哈希以 `common-event-contract` 为准。 ## 快速索引 | 功能 | 入口 | |---|---| -| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` | -| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) | -| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` | -| 校验与 Inbox 编排 | `service/EventInboxService.java` | -| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | -| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | +| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | +| Inbox 接收与幂等 | `service/EventInboxService.java` | +| 海外调度与重试 | `service/OverseasEventDispatcher.java` | +| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | +| 人工补投 | `service/EventReplayService.java` | +| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | -| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` | -| 表结构 | `src/main/resources/db/event-bridge.sql` | -| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | -| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 | +| 数据库 | `src/main/resources/db/event-bridge.sql` | +| 测试 | `src/test/java/com/baiyue/eventbridge/` | + +## 可靠投递规则 + +- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。 +- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 +- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 +- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 ## 修改与验证 -- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。 -- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。 -- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 -- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。 -- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。 -- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。 -- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。 -- 从父目录运行 `mvn -pl event-bridge-service -am test`。 +- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java index dd0e0b068..a79fbbeb9 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java @@ -1,14 +1,19 @@ package com.baiyue.eventbridge; +import com.baiyue.eventbridge.config.EventDispatcherProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 跨区域事件桥启动入口。 */ @MapperScan("com.baiyue.eventbridge.mapper") @SpringBootApplication +@EnableScheduling +@EnableConfigurationProperties(EventDispatcherProperties.class) public class EventBridgeApplication { public static void main(String[] args) { SpringApplication.run(EventBridgeApplication.class, args); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java new file mode 100644 index 000000000..fffbbea19 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/config/EventDispatcherProperties.java @@ -0,0 +1,34 @@ +package com.baiyue.eventbridge.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.event-dispatcher") +public record EventDispatcherProperties( + URI consumerUrl, + int batchSize, + int maxAttempts, + Duration leaseDuration, + Duration initialBackoff, + Duration maxBackoff, + Duration connectTimeout, + Duration requestTimeout) { + + public EventDispatcherProperties { + consumerUrl = consumerUrl == null + ? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl; + batchSize = batchSize <= 0 ? 50 : batchSize; + maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts; + leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30)); + initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5)); + maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15)); + connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3)); + requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10)); + } + + private static Duration positiveOrDefault(Duration value, Duration fallback) { + return value == null || value.isNegative() || value.isZero() ? fallback : value; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index af0707c23..e26f92210 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.eventbridge.dto.BridgeStatus; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.dto.EventReplayRequest; import com.baiyue.eventbridge.service.EventInboxService; +import com.baiyue.eventbridge.service.EventReplayService; import com.baiyue.eventbridge.security.InternalServiceTokenGuard; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PathVariable; /** * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。 @@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/internal/event-bridge") public class EventBridgeController { - @Resource - private EventInboxService eventInboxService; + @Resource private EventInboxService eventInboxService; + @Resource private EventReplayService eventReplayService; + @Resource private InternalServiceTokenGuard internalServiceTokenGuard; - @Resource - private InternalServiceTokenGuard internalServiceTokenGuard; - - @Value("${baiyue.region:CN}") + @Value("${baiyue.region:OVERSEAS}") private String region; @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") @@ -50,6 +51,18 @@ public class EventBridgeController { return ApiResponse.success(eventInboxService.receive(envelope)); } + @Operation(summary = "人工补投死信事件") + @PostMapping("/events/{eventId}/replay") + public ApiResponse replay( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") String operatorId, + @PathVariable String eventId, + @RequestBody EventReplayRequest request) { + internalServiceTokenGuard.verify(token); + eventReplayService.replayDead(eventId, operatorId, request.reason()); + return ApiResponse.success(null); + } + @Operation(summary = "查询事件桥能力状态") @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")) @GetMapping("/status") diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java new file mode 100644 index 000000000..eaf57c555 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeDeliveryAttempt.java @@ -0,0 +1,24 @@ +package com.baiyue.eventbridge.domain; + +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("bridge_delivery_attempt") +public class BridgeDeliveryAttempt { + @TableId(type = IdType.AUTO) + private Long id; + private String eventId; + private String sourceRegion; + private String targetRegion; + private Integer attemptNo; + private String status; + private String responseCode; + private String errorMessage; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java index 4094be14e..96f15d531 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java @@ -20,7 +20,7 @@ public class BridgeInbox { private Integer eventVersion; private String sourceRegion; private String targetRegion; - private String tenantId; + private String customerId; private String aggregateType; private String aggregateId; private Long aggregateVersion; @@ -32,5 +32,11 @@ public class BridgeInbox { private String payloadHash; private String payloadJson; private String status; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private String lastError; private LocalDateTime receivedAt; + private LocalDateTime processedAt; } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java new file mode 100644 index 000000000..25c4bb324 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReplayRequest.java @@ -0,0 +1,4 @@ +package com.baiyue.eventbridge.dto; + +public record EventReplayRequest(String reason) { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java new file mode 100644 index 000000000..755341428 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeDeliveryAttemptMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.eventbridge.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface BridgeDeliveryAttemptMapper extends BaseMapper { +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 5ec53602f..f93a34ff2 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; /** * 入站事件持久化映射。 */ @Mapper public interface BridgeInboxMapper extends BaseMapper { + @Select(""" + SELECT event_id FROM bridge_inbox + WHERE target_region = 'OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + ORDER BY received_at LIMIT #{limit} + """) + List dispatchCandidates(@Param("limit") int limit); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, + lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) + WHERE event_id=#{eventId} AND target_region='OVERSEAS' + AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) + """) + int claim(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("leaseSeconds") long leaseSeconds); + + @Update(""" + UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner); + + @Update(""" + UPDATE bridge_inbox SET retry_count=retry_count+1, + status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END, + next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL + ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END, + lease_owner=NULL, lease_until=NULL, last_error=#{error} + WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner} + """) + int markFailed(@Param("eventId") String eventId, @Param("owner") String owner, + @Param("maxAttempts") int maxAttempts, + @Param("backoffSeconds") long backoffSeconds, @Param("error") String error); + + @Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}") + int nextAttemptNo(@Param("eventId") String eventId); + + @Update(""" + UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3), + lease_owner=NULL, lease_until=NULL, last_error=#{reason} + WHERE event_id=#{eventId} AND status='DEAD' + """) + int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java index 84f9ee1ab..ba4717409 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java @@ -13,8 +13,11 @@ import java.security.MessageDigest; */ @Component public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") - private String expectedToken; + private final String expectedToken; + + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { + this.expectedToken = expectedToken; + } public void verify(String actualToken) { if (expectedToken == null || expectedToken.isBlank()) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 47042bb03..146bb4a5c 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -1,7 +1,5 @@ package com.baiyue.eventbridge.service; -import jakarta.annotation.Resource; - import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -13,21 +11,20 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; import java.time.LocalDateTime; -import java.util.Objects; /** * 入站事件校验与幂等落库服务。 */ @Service public class EventInboxService { - @Resource - private InboxStore inboxStore; - @Resource - private ObjectMapper objectMapper; + @Resource private InboxStore inboxStore; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.region:CN}") - private String localRegion; + private String localRegion; @Transactional public EventReceiveResult receive(EventEnvelope envelope) { @@ -61,24 +58,9 @@ public class EventInboxService { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); } - if (!hasSameEnvelopeIdentity(existing, envelope)) { - throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件"); - } return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } - private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) { - return Objects.equals(existing.getTenantId(), envelope.tenantId()) - && Objects.equals(existing.getEventType(), envelope.eventType()) - && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) - && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) - && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) - && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) - && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) - && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) - && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()); - } - private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); @@ -87,7 +69,7 @@ public class EventInboxService { inbox.setEventVersion(envelope.eventVersion()); inbox.setSourceRegion(envelope.sourceRegion().name()); inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setTenantId(envelope.tenantId()); + inbox.setCustomerId(envelope.tenantId()); inbox.setAggregateType(envelope.aggregateType()); inbox.setAggregateId(envelope.aggregateId()); inbox.setAggregateVersion(envelope.aggregateVersion()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java new file mode 100644 index 000000000..bc8b76dea --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -0,0 +1,27 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +@Service +public class EventReplayService { + @Resource private BridgeInboxMapper inboxMapper; + + public void replayDead(String eventId, String operatorId, String reason) { + if (operatorId == null || operatorId.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供操作人"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("人工补投必须提供原因"); + } + String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000); + if (inboxMapper.replayDead(eventId, auditReason) != 1) { + throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); + } + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java new file mode 100644 index 000000000..3f2a904ab --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -0,0 +1,55 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpLastlegEventClient implements LastlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + + @Value("${baiyue.security.internal-service-token:}") + private String serviceToken; + + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + String body = objectMapper.writeValueAsString(envelope); + HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + .timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java new file mode 100644 index 000000000..7fa21d3b3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/LastlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface LastlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java index b397cae4c..d68c0f983 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java @@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; -import lombok.RequiredArgsConstructor; +import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; /** * 基于 MyBatis-Plus 的入站幂等存储。 */ @Repository -@RequiredArgsConstructor public class MybatisInboxStore implements InboxStore { - private final BridgeInboxMapper mapper; + @Resource private BridgeInboxMapper mapper; @Override public BridgeInbox findByEventId(String eventId) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java new file mode 100644 index 000000000..39317dbe1 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -0,0 +1,88 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +@Slf4j +public class OverseasEventDispatcher { + @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private LastlegEventClient eventClient; + @Resource private EventDispatcherProperties properties; + private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}") + public void dispatch() { + for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { + dispatchOne(eventId); + } + } + + void dispatchOne(String eventId) { + if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) { + return; + } + + BridgeInbox event = inboxMapper.selectById(eventId); + int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; + int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); + BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + try { + String responseCode = eventClient.deliver(event); + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + throw new IllegalStateException("事件租约已失效,无法确认投递结果"); + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + } catch (Exception exception) { + String error = abbreviate(exception.getMessage(), 1000); + inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); + finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, error); + } + } + + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { + BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); + attempt.setEventId(event.getEventId()); + attempt.setSourceRegion(event.getSourceRegion()); + attempt.setTargetRegion(event.getTargetRegion()); + attempt.setAttemptNo(attemptNo); + attempt.setStatus("PROCESSING"); + attempt.setStartedAt(LocalDateTime.now()); + attemptMapper.insert(attempt); + return attempt; + } + + private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) { + attempt.setStatus(status); + attempt.setResponseCode(responseCode); + attempt.setErrorMessage(error); + attempt.setFinishedAt(LocalDateTime.now()); + attemptMapper.updateById(attempt); + } + + private long backoffSeconds(int attemptNo) { + long initial = properties.initialBackoff().toSeconds(); + int shift = Math.min(Math.max(attemptNo - 1, 0), 20); + long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift; + return Math.min(delay, properties.maxBackoff().toSeconds()); + } + + private static String abbreviate(String value, int maxLength) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= maxLength ? safe : safe.substring(0, maxLength); + } +} diff --git a/event-bridge-service/src/main/resources/application-dev.yml b/event-bridge-service/src/main/resources/application-dev.yml deleted file mode 100644 index 19d0a9ad0..000000000 --- a/event-bridge-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} diff --git a/event-bridge-service/src/main/resources/application-test.yml b/event-bridge-service/src/main/resources/application-test.yml deleted file mode 100644 index 6b8765c21..000000000 --- a/event-bridge-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 834b47a8d..c2f16ae02 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -1,18 +1,25 @@ server: - port: ${EVENT_BRIDGE_PORT:9300} + port: ${EVENT_BRIDGE_PORT:9312} spring: - profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} application: name: event-bridge-service config: import: - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} + username: ${EVENT_BRIDGE_DB_USERNAME:root} + password: ${EVENT_BRIDGE_DB_PASSWORD:123456} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -22,7 +29,17 @@ spring: namespace: ${NACOS_NAMESPACE:} baiyue: - region: ${REGION_CODE:CN} + region: ${REGION_CODE:OVERSEAS} + event-dispatcher: + consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} + max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} + lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} + initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} + max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} + connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} + request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} @@ -35,14 +52,3 @@ management: springdoc: api-docs: path: /v3/api-docs - -logging: - file: - name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}" - logback: - rollingpolicy: - file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}" - max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}" - max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" - total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" - clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql index a7a52b539..4bbfe6bd4 100644 --- a/event-bridge-service/src/main/resources/db/event-bridge.sql +++ b/event-bridge-service/src/main/resources/db/event-bridge.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '租户标识', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java index 5c70836e7..1b8dfb9d2 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java @@ -1,43 +1,29 @@ package com.baiyue.eventbridge.security; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("内部服务令牌校验") -@SpringJUnitConfig(InternalServiceTokenGuard.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token") class InternalServiceTokenGuardTest { - @Resource private InternalServiceTokenGuard guard; - - @AfterEach - void resetToken() { - ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token"); - } @Test - void validTokenAllowsAccess() { + void 正确令牌允许访问() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token"); assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException(); } @Test - void unconfiguredTokenDisablesReceiveEndpoint() { - ReflectionTestUtils.setField(guard, "expectedToken", ""); + void 未配置令牌时默认关闭接收接口() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); assertThatThrownBy(() -> guard.verify("anything")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("接口已关闭"); } @Test - void invalidTokenIsRejected() { - ReflectionTestUtils.setField(guard, "expectedToken", "expected"); + void 错误令牌被拒绝() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected"); assertThatThrownBy(() -> guard.verify("wrong")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("令牌无效"); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index a0e9e5a42..1d5d45152 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.dao.DuplicateKeyException; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; @@ -25,35 +17,17 @@ 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.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@DisplayName("跨区域事件收件箱") -@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class}) -@TestPropertySource(properties = "baiyue.region=OVERSEAS") class EventInboxServiceTest { - @Resource - private ObjectMapper objectMapper; - @MockBean - private InboxStore store; - @Resource - private EventInboxService service; - - @Configuration(proxyBeanMethods = false) - static class TestConfig { - @Bean - ObjectMapper objectMapper() { - return new ObjectMapper(); - } - } - - @AfterEach - void resetRegion() { - ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS"); - } + private final ObjectMapper objectMapper = new ObjectMapper(); + private final InboxStore store = mock(InboxStore.class); + private final EventInboxService service = service("OVERSEAS"); @Test - void firstEventPersistsAndReturnsReceived() { + void 首次事件落库并返回已接收() { EventEnvelope envelope = envelope("sha256:placeholder"); envelope = withHash(envelope); @@ -62,19 +36,31 @@ class EventInboxServiceTest { } @Test - void identicalEventAndDigestDoesNotPersistAgain() { + void 数字规范化上线前的历史摘要仍可接收() { + EventEnvelope source = envelope("sha256:placeholder"); + source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); + String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); + EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); + + assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + } + + @Test + void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope, envelope.payloadHash()); + BridgeInbox existing = existing(envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void sameEventWithDifferentDigestRejectsOverwrite() { + void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) @@ -82,45 +68,20 @@ class EventInboxServiceTest { } @Test - void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherTenant = withTenant(original, "20002"); - when(store.findByEventId(otherTenant.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherTenant)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() { - EventEnvelope original = withHash(envelope("sha256:placeholder")); - EventEnvelope otherEnvelope = withAggregateVersion(original, 2L); - when(store.findByEventId(otherEnvelope.eventId())) - .thenReturn(existing(original, original.payloadHash())); - - assertThatThrownBy(() -> service.receive(otherEnvelope)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("信封身份不一致"); - } - - @Test - void duplicateInsertAfterRaceIsTreatedAsDuplicate() { + void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())) - .thenReturn(null, existing(envelope, envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); } @Test - void targetRegionDifferentFromCurrentDeploymentIsRejected() { - ReflectionTestUtils.setField(service, "localRegion", "CN"); + void 目标区域与当前部署区域不一致时拒绝接收() { + EventInboxService cnService = service("CN"); EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - assertThatThrownBy(() -> service.receive(envelope)) + assertThatThrownBy(() -> cnService.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("目标区域"); } @@ -132,6 +93,14 @@ class EventInboxServiceTest { objectMapper.createObjectNode().put("orderId", "90001"), hash); } + private EventInboxService service(String region) { + EventInboxService service = new EventInboxService(); + ReflectionTestUtils.setField(service, "inboxStore", store); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "localRegion", region); + return service; + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), @@ -140,32 +109,9 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private EventEnvelope withTenant(EventEnvelope source, String tenantId) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(), - source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - - private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) { - return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), - source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(), - source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(), - source.correlationId(), source.causationId(), source.payload(), source.payloadHash()); - } - - private BridgeInbox existing(EventEnvelope envelope, String hash) { + private BridgeInbox existing(String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId(envelope.eventId()); - inbox.setTenantId(envelope.tenantId()); - inbox.setEventType(envelope.eventType()); - inbox.setEventVersion(envelope.eventVersion()); - inbox.setSourceRegion(envelope.sourceRegion().name()); - inbox.setTargetRegion(envelope.targetRegion().name()); - inbox.setAggregateType(envelope.aggregateType()); - inbox.setAggregateId(envelope.aggregateId()); - inbox.setAggregateVersion(envelope.aggregateVersion()); - inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java new file mode 100644 index 000000000..66b740ddf --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +class EventReplayServiceTest { + private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final EventReplayService service = service(); + + @Test + void requeuesDeadEventWithAuditReason() { + when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + + service.replayDead("event-1", "operator-7", "incident-42 approved"); + + verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + } + + @Test + void rejectsEventThatIsNotDead() { + when(mapper.replayDead(anyString(), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry")) + .isInstanceOf(IllegalStateException.class); + } + + private EventReplayService service() { + EventReplayService service = new EventReplayService(); + ReflectionTestUtils.setField(service, "inboxMapper", mapper); + return service; + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java new file mode 100644 index 000000000..f3f727fb0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -0,0 +1,94 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasEventDispatcherTest { + private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private OverseasEventDispatcher dispatcher; + private BridgeInbox event; + + @BeforeEach + void setUp() { + EventDispatcherProperties properties = new EventDispatcherProperties( + URI.create("https://lastleg.internal/events"), 50, 3, + Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), + Duration.ofSeconds(3), Duration.ofSeconds(10)); + dispatcher = new OverseasEventDispatcher(); + ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); + ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "properties", properties); + event = new BridgeInbox(); + event.setEventId("event-1"); + event.setSourceRegion("CN"); + event.setTargetRegion("OVERSEAS"); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + } + + @Test + void successfulDeliveryIsConfirmedAndAudited() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + + @Test + void failedDeliveryIsScheduledForRetry() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void finalFailureIsMovedToDeadState() throws Exception { + event.setRetryCount(2); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + } + + @Test + void eventClaimedByAnotherInstanceIsSkipped() { + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); + } + + private BridgeDeliveryAttempt updatedAttempt() { + ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).updateById(captor.capture()); + return captor.getValue(); + } +} diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md new file mode 100644 index 000000000..8d4aff60e --- /dev/null +++ b/gateway-overseas/AGENTS.md @@ -0,0 +1,30 @@ +# Gateway 模块指引 + +## 职责边界 + +- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 +- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` | +| Audience 校验 | `config/AudienceJwtValidator.java` | +| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | +| 安全配置项 | `config/GatewaySecurityProperties.java` | +| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | +| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | +| 测试 | `src/test/java/com/baiyue/gateway/config/` | + +## 安全规则 + +- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。 +- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。 +- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。 + +## 修改与验证 + +- 定向验证:`mvn -pl gateway -am test`。 +- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 + diff --git a/gateway-overseas/pom.xml b/gateway-overseas/pom.xml new file mode 100644 index 000000000..01f656726 --- /dev/null +++ b/gateway-overseas/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + gateway-overseas + gateway-overseas + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + com.baiyue.shared + baiyue-common-core + ${project.version} + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.bootspring-boot-maven-plugin + diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java new file mode 100644 index 000000000..8514ad509 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/BaiyueGatewayOverseasApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * API 网关启动入口,承载统一路由、认证校验和可信身份头转发。 + */ +@SpringBootApplication +public class BaiyueGatewayOverseasApplication { + public static void main(String[] args) { + SpringApplication.run(BaiyueGatewayOverseasApplication.class, args); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java new file mode 100644 index 000000000..5dae7304a --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -0,0 +1,32 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import reactor.core.publisher.Mono; + +/** + * 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。 + */ +final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager { + private final String expectedAppCode; + + AppScopeAuthorizationManager(String expectedAppCode) { + this.expectedAppCode = expectedAppCode; + } + + @Override + public Mono check(Mono authentication, + AuthorizationContext context) { + return authentication.filter(Authentication::isAuthenticated) + .map(this::hasExpectedScope).map(AuthorizationDecision::new) + .defaultIfEmpty(new AuthorizationDecision(false)); + } + + private boolean hasExpectedScope(Authentication authentication) { + return authentication instanceof JwtAuthenticationToken jwt + && expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code")); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java new file mode 100644 index 000000000..0305a7498 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/AudienceJwtValidator.java @@ -0,0 +1,23 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +final class AudienceJwtValidator implements OAuth2TokenValidator { + private final String requiredAudience; + + AudienceJwtValidator(String requiredAudience) { + this.requiredAudience = requiredAudience; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains(requiredAudience)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT audience 不允许访问海外 API", null)); + } +} 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 new file mode 100644 index 000000000..50a208bca --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,84 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.web.server.SecurityWebFilterChain; +import reactor.core.publisher.Mono; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 + */ +@Configuration +@EnableConfigurationProperties(GatewaySecurityProperties.class) +public class GatewaySecurityConfig { + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + GatewaySecurityProperties properties, + ObjectMapper objectMapper) { + var exchanges = http.authorizeExchange(); + properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll()); + exchanges.pathMatchers("/api/merchant/**") + .access(new AppScopeAuthorizationManager("MERCHANT")); + exchanges.pathMatchers("/api/platform/**") + .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.anyExchange().authenticated(); + return http.csrf(ServerHttpSecurity.CsrfSpec::disable) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }) + .authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper, + HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效"))) + .exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) -> + writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源"))) + .build(); + } + + @Bean + ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { + NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder + .withJwkSetUri(properties.jwkSetUri()) + .jwsAlgorithms(algorithms -> { + algorithms.add(SignatureAlgorithm.RS256); + algorithms.add(SignatureAlgorithm.ES256); + }) + .build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new AudienceJwtValidator(properties.audience()), + new RequiredClaimsJwtValidator())); + return decoder; + } + + 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); + Map body = new LinkedHashMap<>(); + body.put("code", code); + body.put("message", message); + body.put("data", null); + body.put("requestId", exchange.getRequest().getId()); + body.put("timestamp", Instant.now().toString()); + try { + byte[] bytes = mapper.writeValueAsBytes(body); + return exchange.getResponse().writeWith( + Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); + } catch (Exception exception) { + return exchange.getResponse().setComplete(); + } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java new file mode 100644 index 000000000..3dab56ba2 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -0,0 +1,27 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * 海外网关安全配置,只保存验签所需的签发方和公钥集地址。 + */ +@ConfigurationProperties(prefix = "baiyue.security") +public record GatewaySecurityProperties(String jwkSetUri, + String issuer, + String audience, + List permitPaths) { + public GatewaySecurityProperties { + if (jwkSetUri == null || jwkSetUri.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI"); + } + if (issuer == null || issuer.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_ISSUER"); + } + if (audience == null || audience.isBlank()) { + throw new IllegalArgumentException("必须配置 JWT_AUDIENCE"); + } + permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); + } +} 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 new file mode 100644 index 000000000..d8621a342 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/RequiredClaimsJwtValidator.java @@ -0,0 +1,26 @@ +package com.baiyue.gateway.config; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.util.List; + +/** 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"); + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + for (String claim : REQUIRED_CLAIMS) { + Object value = jwt.getClaims().get(claim); + if (value == null || value.toString().isBlank()) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error( + "invalid_token", "JWT 缺少必要声明: " + claim, null)); + } + } + return OAuth2TokenValidatorResult.success(); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java new file mode 100644 index 000000000..80b2b320b --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java @@ -0,0 +1,73 @@ +package com.baiyue.gateway.filter; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +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; + +import java.util.List; + +/** + * 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。 + */ +@Component +public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered { + private static final List TRUSTED_HEADERS = List.of( + "X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id", + "X-App-Code", "X-Client-Type" + ); + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return exchange.getPrincipal() + .cast(Authentication.class) + .defaultIfEmpty(new AnonymousAuthentication()) + .flatMap(authentication -> { + var requestBuilder = exchange.getRequest().mutate(); + requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove)); + + if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) { + put(requestBuilder, "X-User-Id", jwt.getToken().getSubject()); + put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id")); + put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id")); + put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id")); + put(requestBuilder, "X-App-Code", claim(jwt, "app_code")); + put(requestBuilder, "X-Client-Type", claim(jwt, "client_type")); + } + return chain.filter(exchange.mutate().request(requestBuilder.build()).build()); + }); + } + + private static String claim(JwtAuthenticationToken jwt, String name) { + Object value = jwt.getToken().getClaims().get(name); + return value == null ? null : value.toString(); + } + + private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder, + String name, + String value) { + if (value != null && !value.isBlank()) { + builder.header(name, value); + } + } + + @Override + public int getOrder() { + return -100; + } + + private static final class AnonymousAuthentication implements Authentication { + @Override public java.util.Collection getAuthorities() { return List.of(); } + @Override public Object getCredentials() { return ""; } + @Override public Object getDetails() { return null; } + @Override public Object getPrincipal() { return "anonymous"; } + @Override public boolean isAuthenticated() { return false; } + @Override public void setAuthenticated(boolean isAuthenticated) { } + @Override public String getName() { return "anonymous"; } + } +} + diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml new file mode 100644 index 000000000..c1c32029e --- /dev/null +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -0,0 +1,69 @@ +spring: + config: + activate: + on-profile: overseas + cloud: + gateway: + discovery: + locator: + enabled: false + routes: + - id: lastleg-service + uri: lb://lastleg-service + predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"] + - id: warehouse-overseas-service + uri: lb://warehouse-overseas-service + predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"] + - id: file-service + uri: lb://file-service + predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"] + - id: print-service + uri: lb://print-service + predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"] + - id: lastleg-openapi + uri: lb://lastleg-service + predicates: [Path=/lastleg-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: warehouse-openapi + uri: lb://warehouse-overseas-service + predicates: [Path=/warehouse-overseas-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: file-openapi + uri: lb://file-service + predicates: [Path=/file-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: print-openapi + uri: lb://print-service + predicates: [Path=/print-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: audit-openapi + uri: lb://audit-service + predicates: [Path=/audit-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: integration-openapi + uri: lb://integration-service + predicates: [Path=/integration-service/v3/api-docs/**] + filters: [StripPrefix=1] + - id: event-bridge-openapi + uri: lb://event-bridge-service + predicates: [Path=/event-bridge-service/v3/api-docs/**] + filters: [StripPrefix=1] + +baiyue: + deployment: + region-code: ${REGION_CODE:OVERSEAS} + cell: ${DEPLOYMENT_CELL:prod-overseas} + data-residency: ${DATA_RESIDENCY:OVERSEAS} + +springdoc: + swagger-ui: + urls: + - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } + - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } + - { name: file-platform, url: /file-service/v3/api-docs/platform } + - { name: file-merchant, url: /file-service/v3/api-docs/merchant } + - { name: print-platform, url: /print-service/v3/api-docs/platform } + - { name: print-merchant, url: /print-service/v3/api-docs/merchant } + - { name: audit, url: /audit-service/v3/api-docs } + - { name: integration, url: /integration-service/v3/api-docs } + - { name: event-bridge, url: /event-bridge-service/v3/api-docs } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml new file mode 100644 index 000000000..053a362a3 --- /dev/null +++ b/gateway-overseas/src/main/resources/application.yml @@ -0,0 +1,67 @@ +server: + port: ${GATEWAY_PORT:9300} + +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: gateway-overseas + profiles: + default: overseas + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config: + namespace: ${NACOS_NAMESPACE:public} + gateway: + discovery: + locator: + enabled: false + +baiyue: + security: + 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} + permit-paths: + - /actuator/health + - /swagger-ui/** + - /webjars/** + - /v3/api-docs/** + - /lastleg-service/v3/api-docs/** + - /warehouse-overseas-service/v3/api-docs/** + - /file-service/v3/api-docs/** + - /print-service/v3/api-docs/** + - /audit-service/v3/api-docs/** + - /integration-service/v3/api-docs/** + - /event-bridge-service/v3/api-docs/** + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java new file mode 100644 index 000000000..21e2371a7 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -0,0 +1,55 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; + +class AppScopeAuthorizationManagerTest { + @Test + void acceptsMatchingApplicationScope() { + var manager = new AppScopeAuthorizationManager("MERCHANT"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsDifferentApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + @Test + void rejectsMissingApplicationScope() { + var manager = new AppScopeAuthorizationManager("PLATFORM"); + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .build(); + StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken( + jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null)) + .expectNextMatches(decision -> !decision.isGranted()) + .verifyComplete(); + } + + private JwtAuthenticationToken authentication(String appCode) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "none") + .subject("1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(60)) + .claim("app_code", appCode) + .build(); + return new JwtAuthenticationToken(jwt, + java.util.List.of(new SimpleGrantedAuthority("authenticated"))); + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java new file mode 100644 index 000000000..474c2ec6f --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/AudienceJwtValidatorTest.java @@ -0,0 +1,33 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class AudienceJwtValidatorTest { + private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api"); + + @Test + void acceptsTokenForLastlegApi() { + assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse(); + } + + @Test + void rejectsTokenForAnotherApi() { + assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue(); + } + + private Jwt jwt(List audiences) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .audience(audiences) + .build(); + } +} 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 new file mode 100644 index 000000000..db83eae5a --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/RequiredClaimsJwtValidatorTest.java @@ -0,0 +1,36 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class RequiredClaimsJwtValidatorTest { + private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator(); + + @Test + void acceptsCompleteIdentityClaims() { + assertThat(validator.validate(jwt(true)).hasErrors()).isFalse(); + } + + @Test + void rejectsMissingPermissionVersion() { + assertThat(validator.validate(jwt(false)).hasErrors()).isTrue(); + } + + private Jwt jwt(boolean includePermissionVersion) { + Jwt.Builder builder = Jwt.withTokenValue("token") + .header("alg", "RS256") + .subject("1001") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", "2001") + .claim("app_code", "PLATFORM"); + if (includePermissionVersion) { + builder.claim("permission_version", 3L); + } + return builder.build(); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md new file mode 100644 index 000000000..7a71049e7 --- /dev/null +++ b/lastleg-service/AGENTS.md @@ -0,0 +1,72 @@ +# Lastleg Service 模块指引 + +## 职责与边界 + +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。 +- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 订单查询与更新 | `service/LastlegOrderService.java` | +| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | +| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | +| 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` | +| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | +| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` | +| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | +| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | +| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | +| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | +| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` | +| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/lastleg/` | + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 | +| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | +| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 | +| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | +| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | +| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | +| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 +- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 +- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 + +## 修改与验证 + +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 +- 定向验证:`mvn -pl lastleg -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml new file mode 100644 index 000000000..4f86b09f9 --- /dev/null +++ b/lastleg-service/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml + lastleg-service + + com.baiyue.sharedcommon-event-contract${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} +com.baomidoumybatis-plus-spring-boot3-starter + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-security${project.version} + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + com.alibaba.cloudspring-cloud-starter-stream-rocketmq + org.springframework.bootspring-boot-starter-actuator + com.mysqlmysql-connector-jruntime + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java new file mode 100644 index 000000000..3b63ff22f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 + */ +@MapperScan("com.baiyue.lastleg.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueLastlegApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java new file mode 100644 index 000000000..abb4a10cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。 + */ +@Tag( + name = "Last-leg service status / 尾程服务状态", + description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。" +) +@RestController +@RequestMapping("/api/platform/v1/last-leg") +public class LastlegHealthController { + @Operation( + summary = "Get service status / 查询服务状态", + description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。" + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Service is available / 服务可用" + ) + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP")); + } +} 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 new file mode 100644 index 000000000..d3373c2db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -0,0 +1,113 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +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.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; + +@RestController +@Tag(name = "Tail-leg orders") +public class LastlegOrderController { + @Resource private LoadingCompletedConsumer consumer; + @Resource private LastlegOrderService orderService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/internal/lastleg/events") + @Operation(summary = "Receive a first-leg loading-completed event") + 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)); + } + + @GetMapping("/api/platform/v1/last-leg/orders") + @Operation(summary = "Page tail-leg order rows") + public ApiResponse> list( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.page(scope(user, customerId), query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @Operation(summary = "Get one inbound-code-level tail-leg order") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(orderService.detail(scope(user, customerId), id)); + } + + @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + public ResponseEntity export( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid LastlegOrderRequests.Query query) { + byte[] body = orderService.exportCsv(scope(user, customerId), query); + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") + .body(body); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @Operation(summary = "Transfer one order to a destination warehouse") + public ApiResponse transferOne( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/destination") + @Operation(summary = "Transfer multiple orders to a destination warehouse") + public ApiResponse transferBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { + return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java new file mode 100644 index 000000000..8d19ed2a3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/DeliveryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.delivery; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.delivery.mapper") +public class DeliveryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java new file mode 100644 index 000000000..49088ef0a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg") +@Tag(name = "Outbound and delivery") +public class DeliveryTransferController { + @Resource private DeliveryTransferService service; + + @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") + @Operation(summary = "Create one pending outbound order and one pending delivery order") + public ApiResponse transfer( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DeliveryRequests.Transfer request) { + return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); + } + + @GetMapping("/outbound-orders") + public ApiResponse> outboundPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); + } + + @GetMapping("/outbound-orders/{outboundNo}") + public ApiResponse outboundDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); + } + + @GetMapping("/delivery-orders") + public ApiResponse> deliveryPage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid DeliveryRequests.Query query) { + return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); + } + + @GetMapping("/delivery-orders/{deliveryNo}") + public ApiResponse deliveryDetail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { + return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java new file mode 100644 index 000000000..ac8083c6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class DeliveryRequests { + private DeliveryRequests() {} + + public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String documentNo, String warehouseId, String status) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, + String destinationWarehouseCode) { + public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java new file mode 100644 index 000000000..643058a20 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.dto; + +import com.baiyue.common.core.api.PageResult; +import java.time.LocalDateTime; + +public final class DeliveryResponses { + private DeliveryResponses() {} + + public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, + String deliveryNo, String deliveryStatus, int palletCount, + int packageCount, boolean replayed) {} + + public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, + String status, int palletCount, int packageCount, LocalDateTime createdAt) {} + + public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo, + String overseasWarehouseId, String status, int palletCount, + int packageCount, LocalDateTime createdAt) {} + + public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode, + String orderRefs, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount) {} + + public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, + PageResult pallets) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java new file mode 100644 index 000000000..ee1b4f809 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.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; + +/** Delivery document linked one-to-one to its outbound document and dispatch. */ +@Data +@TableName("ll_delivery_order") +public class DeliveryOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String deliveryNo; + private Long outboundOrderId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java new file mode 100644 index 000000000..0f438791f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.delivery.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; + +/** Outbound document created when a dispatch is transferred to delivery. */ +@Data +@TableName("ll_outbound_order") +public class OutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String outboundNo; + private Long dispatchOrderId; + private String dispatchNo; + private String overseasWarehouseId; + private String status; + private Integer packageCount; + private Integer palletCount; + private String idempotencyKey; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java new file mode 100644 index 000000000..8145f8f82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.delivery.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; + +/** Pallet-level immutable snapshot belonging to one outbound document. */ +@Data +@TableName("ll_outbound_pallet_item") +public class OutboundPalletItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long outboundOrderId; + private Long warehousePalletId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Integer packageCount; + private String orderRefs; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java new file mode 100644 index 000000000..7d2641cf5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DeliveryOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") + DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE") + DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java new file mode 100644 index 000000000..e16968f70 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface OutboundOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") + OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java new file mode 100644 index 000000000..32bb24aa1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface OutboundPalletItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") + List findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java new file mode 100644 index 000000000..b7e335447 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@Service +public class DeliveryTransferService { + @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchItemMapper dispatchItems; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private OutboundPalletItemMapper outboundPallets; + @Resource private DeliveryOrderMapper deliveryOrders; + @Resource private InboundPlanPalletAllocationMapper planAllocations; + @Resource private InboundPlanLineMapper planLines; + @Resource private LastlegOrderMapper tailOrders; + @Resource private LastlegOrderStatusHistoryMapper histories; + + @Transactional + public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId, + DeliveryRequests.Transfer request) { + DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); + if (found == null) throw notFound("Dispatch order not found"); + DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); + OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); + return result(replay, requireDelivery(customerId, replay.getId()), true); + } + OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true); + if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { + throw conflict("Dispatch cannot be transferred to delivery in its current status"); + } + List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); + if (items.isEmpty()) throw conflict("Dispatch has no pallets"); + if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All dispatch pallets must be received before transfer to delivery"); + } + if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); + + LocalDateTime now = LocalDateTime.now(); + OutboundOrder outbound = new OutboundOrder(); + outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); + outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId()); + outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); + outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); + outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); + outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException e) { + // The unique keys are the final concurrent guard. A client retry must observe + // the document created by the winning transaction rather than receive a false failure. + OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); + if (concurrent != null) { + return result(concurrent, requireDelivery(customerId, concurrent.getId()), true); + } + throw conflict("Dispatch was concurrently transferred to delivery"); + } + + for (DispatchItem source : items) { + OutboundPalletItem item = new OutboundPalletItem(); + item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); + item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); + item.setDestinationWarehouseId(source.getDestinationWarehouseId()); + item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + item.setDestinationWarehouseName(source.getDestinationWarehouseName()); + item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); + item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); + outboundPallets.insert(item); + } + + DeliveryOrder delivery = new DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); + delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId()); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); + delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount()); + delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId); + delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); + bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); + return result(outbound, delivery, false); + } + + public PageResult outboundPage(String customerId, DeliveryRequests.Query query) { + Page page = outboundOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status()) + .orderByDesc(OutboundOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::outboundRow).toList()); + } + + public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo, + DeliveryRequests.PalletQuery query) { + OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo); + if (outbound == null) throw notFound("Outbound order not found"); + DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); + Page page = outboundPallets.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(OutboundPalletItem::getCustomerId, customerId) + .eq(OutboundPalletItem::getOutboundOrderId, outbound.getId()) + .eq(StringUtils.hasText(query.destinationWarehouseCode()), + OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode()) + .orderByAsc(OutboundPalletItem::getId)); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), + page(page, page.getRecords().stream().map(this::palletRow).toList())); + } + + public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { + Page page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DeliveryOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo()) + .eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId()) + .eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status()) + .orderByDesc(DeliveryOrder::getCreatedAt)); + return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); + } + + public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo, + DeliveryRequests.PalletQuery query) { + DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("Delivery order not found"); + OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId()); + if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found"); + return outboundDetail(customerId, outbound.getOutboundNo(), query); + } + + private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, + String remark, LocalDateTime now) { + Set orderIds = new LinkedHashSet<>(); + items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); + List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); + if (!palletIds.isEmpty()) { + List allocations = planAllocations.selectList( + new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); + List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) + .filter(Objects::nonNull).distinct().toList(); + if (!lineIds.isEmpty()) { + planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) + .filter(Objects::nonNull).forEach(orderIds::add); + } + } + if (orderIds.isEmpty()) return; + for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) { + String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); + order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); + order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); + tailOrders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); + history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); + history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); + history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); + history.setCreatedAt(now); histories.insert(history); + } + } + + private DeliveryOrder requireDelivery(String customerId, Long outboundId) { + DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId); + if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order"); + return value; + } + private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) { + return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), + d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); + } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } + private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); } + private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java new file mode 100644 index 000000000..bf22c3ae2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/DiscardMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.discard; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.discard.mapper") +public class DiscardMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java new file mode 100644 index 000000000..e0a1a3284 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/cargo-discards") +public class CargoDiscardController { + @Resource private CargoDiscardService service; + + @GetMapping("/inventory-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String inboundCode) { + return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{discardNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo) { + return ApiResponse.success(service.detail(scope(user, customerId), discardNo)); + } + + @PostMapping("/{discardNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String discardNo, + @Valid @RequestBody CargoDiscardRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required"); + } + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java new file mode 100644 index 000000000..415d0f873 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardRequests { + private CargoDiscardRequests() {} + + public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode, + @Positive int discardPackages) {} + + public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, + String discardNo, String inboundCode, String overseasWarehouseId, + String status, LocalDateTime createdFrom, LocalDateTime createdTo) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java new file mode 100644 index 000000000..7d906b439 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.discard.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class CargoDiscardResponses { + private CargoDiscardResponses() {} + + public record Candidate(Long orderId, String inboundCode, String inboundOrderNo, + String overseasWarehouseId, String overseasWarehouseName, + LocalDateTime inboundCreatedAt, long availablePackages, + List skus) {} + public record CandidateSku(String skuCode, String productName, long availablePackages) {} + + public record Summary(Long id, String discardNo, String inboundCodes, + String overseasWarehouseId, String overseasWarehouseName, + int totalPackages, BigDecimal totalFee, String currency, String status, + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {} + + public record Item(Long id, Long orderId, String inboundCode, String skuCode, + String productName, int availablePackagesSnapshot, int discardPackages) {} + + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + + public record Detail(Summary summary, BigDecimal feePerPackage, String remark, + String reviewedBy, String reviewRemark, List items, + List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java new file mode 100644 index 000000000..369ece1f4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.discard.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_cargo_discard_application") +public class CargoDiscardApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String discardNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String inboundCodes; + private Integer totalPackages; + private BigDecimal feePerPackage; + private BigDecimal totalFee; + private String currency; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private LocalDateTime reviewedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java new file mode 100644 index 000000000..b8319c505 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.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_cargo_discard_carton") +public class CargoDiscardCarton { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private Long itemId; + private String customerId; + private Long cartonId; + private String cartonMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java new file mode 100644 index 000000000..7912792b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.discard.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_cargo_discard_item") +public class CargoDiscardItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private Long orderId; + private String inboundCode; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Integer availablePackagesSnapshot; + private Integer discardPackages; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java new file mode 100644 index 000000000..e2caa34bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.discard.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_cargo_discard_status_history") +public class CargoDiscardStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java new file mode 100644 index 000000000..64a70ec3e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface CargoDiscardApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1") + CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE") + CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java new file mode 100644 index 000000000..e672a85ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface CargoDiscardCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java new file mode 100644 index 000000000..93a8178cc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface CargoDiscardInventoryMapper { + @Select("") + List candidates(@Param("customerId") String customerId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode); + + @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'") + long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("profileId") Long profileId, @Param("skuCode") String skuCode); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE") + List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("skuCode") String skuCode, @Param("limit") int limit); + + @Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}") + int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE") + DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") + DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("profileId") Long profileId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " + + "WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}") + int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") int packages); + + @Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}") + Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java new file mode 100644 index 000000000..19ea10c5c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface CargoDiscardItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java new file mode 100644 index 000000000..816a41e29 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.discard.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface CargoDiscardStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id") + List listApplication(@Param("customerId") String customerId, + @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java new file mode 100644 index 000000000..90abc3e1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class CargoDiscardCandidateRow { + private Long orderId; + private String inboundCode; + private String inboundOrderNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private LocalDateTime createdAt; + private Long profileId; + private Long skuInventoryId; + private String skuCode; + private String productName; + private Long availablePackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java new file mode 100644 index 000000000..1691f7dda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.discard.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** Final inventory mutation for a warehouse-approved cargo discard. */ +@Service +public class CargoDiscardInventoryAdjustmentService { + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private DropshipCartonLedgerMapper cartonLedgers; + + @Transactional(propagation = Propagation.MANDATORY) + public AdjustmentResult discard(String customerId, CargoDiscardApplication application, + List items, + Map> linksByItem, + String operatorId, LocalDateTime occurredAt) { + long beforePackages = 0; + long discardedPackages = 0; + long afterPackages = 0; + for (CargoDiscardItem item : items) { + DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId()); + if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode()); + long before = value(sku.getPackageCount()); + int discarded = item.getDiscardPackages(); + if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) { + throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode()); + } + List links = linksByItem.getOrDefault(item.getId(), List.of()); + if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete"); + for (CargoDiscardCarton link : links) { + DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId()); + if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(), + "DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval"); + cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt)); + } + Long persistedAfter = inventory.skuPackages(customerId, sku.getId()); + long expectedAfter = before - discarded; + if (persistedAfter == null || persistedAfter != expectedAfter) { + throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode()); + } + beforePackages = Math.addExact(beforePackages, before); + discardedPackages = Math.addExact(discardedPackages, discarded); + afterPackages = Math.addExact(afterPackages, persistedAfter); + } + if (beforePackages - discardedPackages != afterPackages + || discardedPackages != application.getTotalPackages()) { + throw conflict("Cargo discard application package conservation check failed"); + } + return new AdjustmentResult(beforePackages, discardedPackages, afterPackages); + } + + private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application, + CargoDiscardItem item, DropshipCarton carton, + String operatorId, LocalDateTime occurredAt) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode()); + fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(application.getDiscardNo()); + fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId()); + fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark())); + fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static String remarkSuffix(String value) { + return value == null || value.isBlank() ? "" : "; " + value.trim(); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + + public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) { + public String auditRemark(String reviewRemark) { + String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; "; + return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages + + ", inventoryAfter=" + afterPackages; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java new file mode 100644 index 000000000..2f366c605 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -0,0 +1,303 @@ +package com.baiyue.lastleg.discard.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class CargoDiscardService { + public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + + @Resource private CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper discardCartons; + @Resource private CargoDiscardStatusHistoryMapper histories; + @Resource private CargoDiscardInventoryMapper inventory; + @Resource private CargoDiscardInventoryAdjustmentService adjustments; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}") + private BigDecimal feePerPackage = new BigDecimal("5.00"); + + public List candidates(String customerId, String inboundCode) { + List rows = inventory.candidates(customerId, trim(inboundCode)); + Map grouped = new LinkedHashMap<>(); + for (CargoDiscardCandidateRow row : rows) { + CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row)); + value.add(row); + } + return grouped.values().stream().map(CandidateAccumulator::response).toList(); + } + + @Transactional + public CargoDiscardResponses.Detail create(String customerId, String operatorId, + CargoDiscardRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + CargoDiscardApplication replay = applications.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different cargo discard data"); + } + return detail(customerId, replay.getDiscardNo()); + } + Set requestedDimensions = new LinkedHashSet<>(); + Map orderCache = new HashMap<>(); + String warehouseId = null; + String warehouseName = null; + int total = 0; + for (CargoDiscardRequests.Item input : request.items()) { + String dimension = input.orderId() + ":" + input.skuCode().trim(); + if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension); + LastlegOrder order = requireDropshipOrder(customerId, input.orderId()); + orderCache.put(order.getId(), order); + if (warehouseId == null) { + warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse"); + warehouseName = trim(order.getOverseasWarehouseName()); + } else if (!warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("One cargo discard application cannot span operating warehouses"); + } + total = Math.addExact(total, input.discardPackages()); + } + + LocalDateTime now = LocalDateTime.now(); + CargoDiscardApplication application = new CargoDiscardApplication(); + application.setId(IdWorker.getId()); application.setCustomerId(customerId); + application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId()); + application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); + application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode) + .distinct().sorted().collect(java.util.stream.Collectors.joining(","))); + application.setTotalPackages(total); application.setFeePerPackage(feePerPackage); + application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total))); + application.setCurrency("USD"); application.setStatus(PENDING_REVIEW); + application.setRemark(trim(request.remark())); application.setIdempotencyKey(key); + application.setRequestHash(requestHash); application.setCreatedBy(operatorId); + application.setCreatedAt(now); application.setUpdatedAt(now); + applications.insert(application); + + for (CargoDiscardRequests.Item input : request.items()) { + reserve(customerId, application, orderCache.get(input.orderId()), input, now); + } + history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, application.getDiscardNo()); + } + + public PageResult page(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) + .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, + query.overseasWarehouseId()) + .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) + .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) + .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); + List records = result.getRecords().stream().map(this::summary).toList(); + return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); + } + + public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { + CargoDiscardApplication application = applications.findNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + List itemViews = items.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), + value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages())) + .toList(); + List historyViews = histories.listApplication(customerId, application.getId()).stream() + .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), + value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList(); + return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(), + application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews); + } + + @Transactional + public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId, + CargoDiscardRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + CargoDiscardApplication application = applications.lockNo(customerId, discardNo); + if (application == null) throw notFound("Cargo discard application not found"); + if (next.equals(application.getStatus())) return detail(customerId, discardNo); + if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed"); + + List applicationItems = items.listApplication(customerId, application.getId()); + List links = discardCartons.listApplication(customerId, application.getId()); + Map> linksByItem = links.stream() + .collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId)); + String historyRemark = request.remark(); + if (APPROVED.equals(next)) { + CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard( + customerId, application, applicationItems, linksByItem, operatorId, now()); + historyRemark = result.auditRemark(request.remark()); + } else { + release(customerId, links); + } + + LocalDateTime reviewedAt = now(); + application.setStatus(next); application.setReviewedBy(operatorId); + application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt); + application.setUpdatedAt(reviewedAt); applications.updateById(application); + history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt); + return detail(customerId, discardNo); + } + + private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order, + CargoDiscardRequests.Item input, LocalDateTime now) { + String skuCode = input.skuCode().trim(); + if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) { + throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode()); + } + List selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages()); + if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request"); + Map> byProfile = selected.stream() + .collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new, + java.util.stream.Collectors.toList())); + for (Map.Entry> entry : byProfile.entrySet()) { + DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode); + if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo"); + CargoDiscardItem item = new CargoDiscardItem(); + item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId); + item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey()); + item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName()); + item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile( + customerId, order.getId(), entry.getKey(), skuCode))); + item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item); + for (DropshipCarton carton : entry.getValue()) { + if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) { + throw conflict("Cargo was reserved concurrently; retry the discard request"); + } + CargoDiscardCarton link = new CargoDiscardCarton(); + link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId()); + link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); + link.setCreatedAt(now); discardCartons.insert(link); + } + } + } + + private void release(String customerId, List links) { + for (CargoDiscardCarton link : links) { + if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) { + throw conflict("Reserved cargo status changed before rejection"); + } + } + } + + private LastlegOrder requireDropshipOrder(String customerId, Long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order"); + } + return order; + } + + private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) { + return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(), + value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(), + value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), + value.getReviewedAt()); + } + + private void history(String customerId, Long applicationId, String from, String to, String operatorId, + String remark, LocalDateTime createdAt) { + CargoDiscardStatusHistory value = new CargoDiscardStatusHistory(); + value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId); + value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId); + value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value); + } + + private void validateQuery(CargoDiscardRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status"); + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash cargo discard request", exception); + } + } + + private static LocalDateTime now() { return LocalDateTime.now(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private static final class CandidateAccumulator { + private final CargoDiscardCandidateRow first; + private final Map skus = new LinkedHashMap<>(); + private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; } + private void add(CargoDiscardCandidateRow row) { + skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName())) + .packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages(); + } + private CargoDiscardResponses.Candidate response() { + List skuViews = skus.values().stream() + .map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList(); + long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); + return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(), + first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews); + } + } + private static final class CandidateSkuAccumulator { + private final String skuCode; + private final String productName; + private long packages; + private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java new file mode 100644 index 000000000..9a4339977 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/DispatchMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.mapper") +public class DispatchMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java new file mode 100644 index 000000000..9cf6b7f44 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -0,0 +1,134 @@ +package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg vehicle dispatch") +public class DispatchController { + @Resource private DispatchService service; + @Resource private DispatchPdaWorkflowService pdaWorkflow; + + @GetMapping("/candidates") + @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage) { + return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage)); + } + + @PostMapping("/pre-arrange") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchRequests.PreArrange request) { + String scopedTenant = scope(user, customerId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); + } + + @GetMapping + @Operation(summary = "Page dispatch orders") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid DispatchRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + + @GetMapping("/{dispatchNo}") + @Operation(summary = "Get dispatch details and all pallet destinations") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo) { + return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo)); + } + + @PutMapping("/{dispatchNo}") + @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchRequests.Update request) { + return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request)); + } + + @DeleteMapping("/{dispatchNo}") + @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + public ApiResponse cancel( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo) { + service.cancel(scope(user, customerId), dispatchNo); + return ApiResponse.success(null); + } + + @PostMapping("/{dispatchNo}/depart") + @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") + public ApiResponse depart( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable String dispatchNo, + @Valid @RequestBody DispatchPdaRequests.Depart request) { + requirePdaOperate(user); + String scopedTenant = scope(user, customerId); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } + + private void requirePdaOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + DispatchPdaController.OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java new file mode 100644 index 000000000..370d81171 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class DispatchRequests { + private DispatchRequests() { + } + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, + String vehiclePlate, String status, String fbaAppointmentNo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, + @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus) { + } + + public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, + @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, + @Size(max = 255) String route, LocalDateTime departAt, + @Size(max = 64) String fbaAppointmentNo, + @Size(max = 32) String fbaAppointmentStatus, + @Size(max = 32) String vehicleAppointmentStatus, + LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java new file mode 100644 index 000000000..5e7d2a81d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName, + String driverPhone, String carrierName, String orderNos, String route, + String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt, + String fbaAppointmentNo, String fbaAppointmentStatus, + String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, + int itemCount, int palletCount, int packageCount, int pickedPackageCount, + int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, + LocalDateTime createdAt, + List items) { + public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, + String overseasWarehouseId, String overseasWarehouseName, + String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int packageCount, + BigDecimal volumeCbm, String sourceStage) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java new file mode 100644 index 000000000..2fa8e73d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Immutable pallet/cargo snapshot selected into a dispatch order. */ +@Data +@TableName("ll_dispatch_item") +public class DispatchItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long dispatchOrderId; + private String customerId; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + /** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */ + private String sourceStage; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java new file mode 100644 index 000000000..78caeb77d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.dispatch.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; + +/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */ +@Data +@TableName("ll_dispatch_order") +public class DispatchOrder { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String dispatchNo; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String vehiclePlate; + private String driverName; + private String driverPhone; + private String carrierName; + private String route; + private LocalDateTime departAt; + private String fbaAppointmentNo; + private String fbaAppointmentStatus; + private String vehicleAppointmentStatus; + private LocalDateTime estimatedArrivalAt; + private String status; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String partialCompletionReason; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java new file mode 100644 index 000000000..386d77e17 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.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; + +/** Prevents a source pallet from being added to more than one active dispatch order. */ +@Data +@TableName("ll_dispatch_pallet_lock") +public class DispatchPalletLock { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String sourcePalletKey; + /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + private String activeKey; + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long dispatchOrderId; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java new file mode 100644 index 000000000..2d0b37abc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface DispatchCandidateMapper { + @Select("") + List available(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode, + @Param("sourceStage") String sourceStage); + + @Select("") + List lockSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java new file mode 100644 index 000000000..2dff34801 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface DispatchItemMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") + List findByDispatchOrder(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); + + @Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " + + "JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " + + "SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " + + "WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " + + "AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL") + int bindReceivedPallets(@Param("customerId") String customerId, + @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java new file mode 100644 index 000000000..2a46cf219 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") + DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); + + @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") + DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java new file mode 100644 index 000000000..ba5a4e5e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") + DispatchPalletLock lockActive(@Param("customerId") String customerId, + @Param("sourcePalletKey") String sourcePalletKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java new file mode 100644 index 000000000..27c479c28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.dispatch.model; + +import java.math.BigDecimal; + +public record DispatchPalletCandidate( + Long planId, + Long planPalletId, + Long warehousePalletId, + Long orderId, + String overseasWarehouseId, + String overseasWarehouseName, + String orderNo, + String orderRefs, + String inboundCode, + String palletNo, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal volumeCbm, + String sourceStage +) { + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, + String orderNo, String orderRefs, String inboundCode, String palletNo, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, + BigDecimal volumeCbm, String sourceStage) { + this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode, + palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + fbaWarehouseCode, packageCount, volumeCbm, sourceStage); + } + public String sourcePalletKey() { + // A plan pallet keeps the same lock identity before and after the physical pallet is received. + return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java new file mode 100644 index 000000000..ddfbe6b50 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/DispatchPdaMapperConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper") +public class DispatchPdaMapperConfiguration {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java new file mode 100644 index 000000000..e45211b0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.dispatch.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda") +public class DispatchPdaController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private DispatchPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Push request) { + requireOperate(user); + return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request)); + } + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.task(scope(user, customerId), dispatchNo)); + } + @PostMapping("/items/{itemId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, @PathVariable long itemId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.Pick request) { + requireOperate(user); + return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request)); + } + @PostMapping("/complete") + public ApiResponse completePicking( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { + requireOperate(user); + return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java new file mode 100644 index 000000000..e98c0cdcc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class DispatchPdaRequests { + private DispatchPdaRequests() {} + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName, + String warehouseId) { + public Push(String idempotencyKey, String operatorId, String operatorName) { + this(idempotencyKey, operatorId, operatorName, null); + } + } + public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark, String warehouseId) { + public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { + this(positionId, packages, productQuantity, idempotencyKey, remark, null); + } + } + public record Depart(@NotBlank String idempotencyKey, String remark) {} + public record CompletePicking(@NotBlank String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java new file mode 100644 index 000000000..638d51be2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.pda.dto; + +import java.util.List; + +public final class DispatchPdaResponses { + private DispatchPdaResponses() {} + public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, + List items) {} + public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages, + long pickedPackages, List positions) {} + public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, + long pickedPackages) {} + public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, + boolean palletScrapped, boolean replayed) {} + public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) { + public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); } + } + public record PickingCompleted(String dispatchNo, String status, long plannedPackages, + long pickedPackages, long remainingPackages, boolean partial, + boolean replayed) { + public PickingCompleted withReplay() { + return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages, + remainingPackages, partial, true); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java new file mode 100644 index 000000000..94637d9a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.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_dispatch_pda_operation") +public class DispatchPdaOperation { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private String operationType; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java new file mode 100644 index 000000000..fe47e73c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.pda.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_dispatch_pda_pick") +public class DispatchPdaPick { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long taskId; + private Long dispatchItemId; + private Long inventoryPositionId; + private Long packages; + private Long productQuantity; + private String idempotencyKey; + private LocalDateTime pickedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java new file mode 100644 index 000000000..4f87b9f53 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.pda.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; + +/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */ +@Data +@TableName("ll_dispatch_pda_pick_order") +public class DispatchPdaPickOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long pickId; + private Long receiptId; + private Long orderId; + private Long packages; + private Long productQuantity; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java new file mode 100644 index 000000000..81ccee623 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.pda.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_dispatch_pda_task") +public class DispatchPdaTask { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long dispatchOrderId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private String warehouseId; + private LocalDateTime pushedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java new file mode 100644 index 000000000..a94613689 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DispatchPdaOperation findKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java new file mode 100644 index 000000000..2b8c0f33c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface DispatchPdaPickMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") + long totalItem(@Param("customerId") String customerId, @Param("taskId") Long taskId, @Param("itemId") Long itemId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java new file mode 100644 index 000000000..c14b0f8a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface DispatchPdaPickOrderMapper extends BaseMapper { + @Select("SELECT r.* FROM ll_inbound_stock_receipt r " + + "WHERE r.customer_id=#{customerId} AND r.inventory_position_id=#{positionId} " + + "AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) FROM ll_dispatch_pda_pick_order a " + + "WHERE a.customer_id=r.customer_id AND a.receipt_id=r.id) ORDER BY r.received_at,r.id") + List availableReceipts(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedPackages(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT COALESCE(SUM(product_quantity),0) FROM ll_dispatch_pda_pick_order " + + "WHERE customer_id=#{customerId} AND receipt_id=#{receiptId}") + long attributedQuantity(@Param("customerId") String customerId, @Param("receiptId") Long receiptId); + + @Select("SELECT * FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND pick_id=#{pickId} ORDER BY id") + List listPick(@Param("customerId") String customerId, @Param("pickId") Long pickId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java new file mode 100644 index 000000000..6c548691e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface DispatchPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + + @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") + DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java new file mode 100644 index 000000000..e205489aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -0,0 +1,277 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class DispatchPdaWorkflowService { + @Resource private DispatchOrderMapper orders; + @Resource private OutboundOrderMapper outboundOrders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPdaTaskMapper tasks; + @Resource private DispatchPdaPickMapper picks; + @Resource private DispatchPdaOperationMapper operations; + @Resource private DispatchPalletLockMapper palletLocks; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryCommandService inventory; + @Resource private DispatchPickOrderAttributionService pickAttribution; + @Resource private ObjectMapper objectMapper; + + @Transactional + public DispatchPdaResponses.Task push(String customerId, String dispatchNo, DispatchPdaRequests.Push request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Task.class); + items.bindReceivedPallets(customerId, order.getId()); + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + if (dispatchItems.isEmpty()) throw conflict("Dispatch has no pallets to push"); + if (dispatchItems.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { + throw conflict("All pre-arranged pallets must be received before PDA push"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new DispatchPdaTask(); + task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(order.getId()); + task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); + task.setWarehouseId(warehouseId); + task.setOperatorId(trim(request.operatorId())); task.setOperatorName(trim(request.operatorName())); + task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); tasks.insert(task); + } + if ("PENDING_DEPARTURE".equals(order.getStatus())) { order.setStatus("PICKING"); order.setUpdatedAt(now); orders.updateById(order); } + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA task warehouse does not match selected warehouse"); + DispatchPdaResponses.Task result = task(customerId, order, task, dispatchItems); + save(customerId, task.getId(), "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional(readOnly = true) + public DispatchPdaResponses.Task task(String customerId, String dispatchNo) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); + if (task == null) throw notFound("Dispatch PDA task not found"); + return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); + } + + @Transactional + public DispatchPdaResponses.Picked pick(String customerId, String dispatchNo, long itemId, + String operatorId, DispatchPdaRequests.Pick request) { + DispatchOrder order = requireOrder(customerId, dispatchNo); + requirePdaStatus(order); + requireVehicle(order); + String warehouseId = warehouse(request.warehouseId()); + requireDispatchWarehouse(order, warehouseId); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for picking"); + } + if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); + DispatchItem item = items.selectById(itemId); + if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { + throw notFound("Dispatch pallet item not found"); + } + if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); + DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Picked.class); + InventoryPosition position = positions.selectById(request.positionId()); + if (position == null || !customerId.equals(position.getCustomerId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to this dispatch pallet"); + } + long picked = picks.totalItem(customerId, task.getId(), itemId); + if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":ALLOCATE", request.remark()); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, request.positionId(), + request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, + keyBase + ":PICK", request.remark())); + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); + pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); + pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); + pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); + pickAttribution.attribute(customerId, pick); + long total = picked + request.packages(); + order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); + task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); + if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) { + task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); + } + order.setUpdatedAt(LocalDateTime.now()); + orders.updateById(order); + tasks.updateById(task); + DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); + save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public DispatchPdaResponses.PickingCompleted completePicking(String customerId, String dispatchNo, + DispatchPdaRequests.CompletePicking request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); + requireVehicle(order); + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { + throw conflict("Dispatch PDA task is not available for completion"); + } + List source = items.findByDispatchOrder(customerId, order.getId()); + long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) + .mapToLong(Integer::longValue).sum(); + long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); + if (picked <= 0 || picked > planned) throw conflict("PDA completion requires a valid picked package count"); + boolean partial = picked < planned; + LocalDateTime now = LocalDateTime.now(); + task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); + order.setStatus(partial ? "PARTIAL_READY_TO_DEPART" : "READY_TO_DEPART"); + order.setPartialCompletionReason(partial ? request.reason().trim() : null); + order.setPickedPackageCount((int) picked); + order.setUpdatedAt(now); orders.updateById(order); + DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), + order.getStatus(), planned, picked, planned - picked, partial, false); + save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); + return result; + } + + /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ + @Transactional + public DispatchPdaResponses.Departed depart(String customerId, String dispatchNo, String operatorId, + DispatchPdaRequests.Depart request) { + DispatchOrder found = requireOrder(customerId, dispatchNo); + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); + DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); + if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); + requireVehicle(order); + OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); + if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { + throw conflict("Dispatch must be transferred to a pending outbound order before departure"); + } + DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + if (task == null || !"COMPLETED".equals(task.getStatus()) + || !("READY_TO_DEPART".equals(order.getStatus()) || "PARTIAL_READY_TO_DEPART".equals(order.getStatus()))) { + throw conflict("Dispatch must complete PDA picking before departure"); + } + + List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); + boolean partial = "PARTIAL_READY_TO_DEPART".equals(order.getStatus()); + if (!partial && !allPicked(customerId, task.getId(), dispatchItems)) { + throw conflict("Every dispatch pallet must be fully picked before departure"); + } + List dispatchPicks = picks.listTask(customerId, task.getId()); + if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); + long shipped = 0; + for (DispatchPdaPick pick : dispatchPicks) { + String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + pick.getInventoryPositionId(), n(pick.getPackages()), + outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); + shipped += n(pick.getPackages()); + } + LocalDateTime now = LocalDateTime.now(); + pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); + outbound.setStatus("OUTBOUNDED"); + outbound.setUpdatedAt(now); + outboundOrders.updateById(outbound); + order.setShippedPackageCount((int) shipped); + order.setStatus(partial ? "PARTIAL_DEPARTED" : "DEPARTED"); + order.setUpdatedAt(now); + orders.updateById(order); + releasePalletLocks(customerId, order.getId(), now); + DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), + order.getStatus(), shipped, false); + save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); + return result; + } + + private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { + List values = new ArrayList<>(); + for (DispatchItem item : source) { + List palletPositions = item.getWarehousePalletId() == null ? List.of() + : positions.listPallet(customerId, item.getWarehousePalletId()); + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> + new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()), + n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); + } + return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), task.getWarehouseId(), task.getStatus(), values); + } + + private boolean allPicked(String customerId, Long taskId, List source) { + return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount()); + } + private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) { + List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + palletLocks.updateById(lock); + } + } + private DispatchOrder requireOrder(String customerId, String dispatchNo) { DispatchOrder order = orders.findByDispatchNo(customerId, dispatchNo); if (order == null) throw notFound("Dispatch order not found"); return order; } + private void requirePdaStatus(DispatchOrder order) { + if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) + || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); + } + private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } + private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } + private static void requireDispatchWarehouse(DispatchOrder order, String warehouseId) { + if (trim(order.getOverseasWarehouseId()) == null || !warehouseId.equals(order.getOverseasWarehouseId().trim())) { + throw conflict("Selected warehouse does not match dispatch warehouse"); + } + } + private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } + private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } + private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } + private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } + private static long n(Long value) { return value == null ? 0 : value; } + private static long n(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java new file mode 100644 index 000000000..441d30d56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +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; +import java.util.Map; + +@Service +public class DispatchPickOrderAttributionService { + @Resource private DispatchPdaPickOrderMapper attributions; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper history; + + /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ + public List attribute(String customerId, DispatchPdaPick pick) { + long remainingPackages = value(pick.getPackages()); + long totalPackages = remainingPackages; + long totalQuantity = value(pick.getProductQuantity()); + long allocatedPackages = 0; + long allocatedQuantity = 0; + Map pickedByOrder = new LinkedHashMap<>(); + for (InboundStockReceipt receipt : attributions.availableReceipts(customerId, + pick.getInventoryPositionId())) { + if (remainingPackages == 0) break; + long receiptPackages = value(receipt.getReceivedPackages()) + - attributions.attributedPackages(customerId, receipt.getId()); + if (receiptPackages <= 0) continue; + long packages = Math.min(remainingPackages, receiptPackages); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + long quantity = nextQuantity - allocatedQuantity; + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); + row.setCustomerId(customerId); + row.setPickId(pick.getId()); + row.setReceiptId(receipt.getId()); + row.setOrderId(receipt.getOrderId()); + row.setPackages(packages); + row.setProductQuantity(quantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + attributions.insert(row); + pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); + remainingPackages -= packages; + allocatedPackages = nextPackages; + allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + for (Map.Entry entry : pickedByOrder.entrySet()) { + if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { + throw conflict("Picked packages exceed tail-leg order remaining packages"); + } + } + return pickedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + /** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */ + public List depart(String customerId, String outboundNo, String dispatchNo, List picks, + LocalDateTime departedAt) { + Map shippedByOrder = new LinkedHashMap<>(); + for (DispatchPdaPick pick : picks) { + for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { + shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); + } + } + for (Map.Entry entry : shippedByOrder.entrySet()) { + LastlegOrder before = orders.lockById(customerId, entry.getKey()); + if (before == null || orders.transferPickedToShipped(customerId, entry.getKey(), entry.getValue(), outboundNo) != 1) { + throw conflict("Tail-leg order picked packages are inconsistent"); + } + long shipped = value(before.getShippedPackageCount()) + entry.getValue(); + String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); + row.setOrderId(entry.getKey()); + row.setFromStatus(before.getCurrentStatus()); + row.setToStatus(to); + row.setEventId("DISPATCH_DEPART:" + dispatchNo); + row.setRemark("Dispatch departed " + entry.getValue() + " packages; cumulative shipped " + + shipped + "/" + value(before.getPackageCount())); + row.setCreatedAt(departedAt); + history.insert(row); + } + return shippedByOrder.entrySet().stream().map(e -> new OrderPackages(e.getKey(), e.getValue())).toList(); + } + + public record OrderPackages(Long orderId, long packages) {} + private static long value(Long value) { return value == null ? 0 : value; } + private static long value(Integer value) { return value == null ? 0 : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java new file mode 100644 index 000000000..bd389d8ea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -0,0 +1,261 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class DispatchService { + public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; + public static final String CANCELLED = "CANCELLED"; + + @Resource private DispatchOrderMapper orders; + @Resource private DispatchItemMapper items; + @Resource private DispatchPalletLockMapper locks; + @Resource private DispatchCandidateMapper candidates; + + public List candidates(String customerId, String orderNo, + String fbaWarehouseCode, String sourceStage) { + if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); + } + return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + } + + @Transactional + public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); + if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); + } + List selected = candidates.lockSelected(customerId, palletIds); + if (selected.size() != palletIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); + } + if (selected.stream().anyMatch(candidate -> candidate.packageCount() == null || candidate.packageCount() <= 0)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Selected pallet has no remaining packages to dispatch"); + } + Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); + if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + } + for (DispatchPalletCandidate candidate : selected) { + boolean planLocked = candidate.planPalletId() != null + && locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()) != null; + boolean receivedLocked = candidate.warehousePalletId() != null + && locks.lockActive(customerId, "RECEIVED:" + candidate.warehousePalletId()) != null; + if (planLocked || receivedLocked) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo()); + } + } + + LocalDateTime now = LocalDateTime.now(); + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(IdWorker.getId()); + dispatch.setCustomerId(customerId); + dispatch.setDispatchNo("PC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + dispatch.getId()); + dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); + dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); + dispatch.setRoute(StringUtils.hasText(request.route()) ? request.route().trim() : route(selected)); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setStatus(PENDING_DEPARTURE); + dispatch.setItemCount(selected.size()); + dispatch.setPalletCount(selected.size()); + dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); + dispatch.setPickedPackageCount(0); + dispatch.setShippedPackageCount(0); + dispatch.setCreatedBy(trimToNull(operatorId)); + dispatch.setCreatedAt(now); + dispatch.setUpdatedAt(now); + orders.insert(dispatch); + + for (DispatchPalletCandidate candidate : selected) { + DispatchPalletLock lock = new DispatchPalletLock(); + lock.setId(IdWorker.getId()); + lock.setCustomerId(customerId); + lock.setSourcePalletKey(candidate.sourcePalletKey()); + lock.setActiveKey(candidate.sourcePalletKey()); + lock.setPlanId(candidate.planId()); + lock.setPlanPalletId(candidate.planPalletId()); + lock.setWarehousePalletId(candidate.warehousePalletId()); + lock.setDispatchOrderId(dispatch.getId()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(now); + lock.setCreatedAt(now); + lock.setUpdatedAt(now); + try { + locks.insert(lock); + } catch (DuplicateKeyException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); + } + DispatchItem item = new DispatchItem(); + item.setId(IdWorker.getId()); + item.setDispatchOrderId(dispatch.getId()); + item.setCustomerId(customerId); + item.setPlanId(candidate.planId()); + item.setPlanPalletId(candidate.planPalletId()); + item.setWarehousePalletId(candidate.warehousePalletId()); + item.setOrderId(candidate.orderId()); + item.setOverseasWarehouseId(candidate.overseasWarehouseId()); + item.setOverseasWarehouseName(candidate.overseasWarehouseName()); + item.setOrderNo(candidate.orderNo()); + item.setOrderRefs(candidate.orderRefs()); + item.setInboundCode(candidate.inboundCode()); + item.setPalletNo(candidate.palletNo()); + item.setDestinationWarehouseId(candidate.destinationWarehouseId()); + item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); + item.setDestinationWarehouseName(candidate.destinationWarehouseName()); + item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); + item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); + item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); + item.setSourceStage(candidate.sourceStage()); + item.setCreatedAt(now); + items.insert(item); + } + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + public PageResult page(String customerId, DispatchRequests.Query query) { + Page page = orders.selectPage(new Page<>(query.page(), query.size()), + new LambdaQueryWrapper().eq(DispatchOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) + .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) + .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, + query.fbaAppointmentNo()) + .orderByDesc(DispatchOrder::getCreatedAt)); + List records = page.getRecords().stream() + .map(order -> response(order, items.findByDispatchOrder(customerId, order.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public DispatchResponse detail(String customerId, String dispatchNo) { + DispatchOrder dispatch = find(customerId, dispatchNo); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + public DispatchResponse update(String customerId, String dispatchNo, DispatchRequests.Update request) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + // Vehicle plate is intentionally editable through all dispatch states. + dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); + dispatch.setDriverName(trimToNull(request.driverName())); + dispatch.setDriverPhone(trimToNull(request.driverPhone())); + dispatch.setCarrierName(trimToNull(request.carrierName())); + dispatch.setRoute(trimToNull(request.route())); + dispatch.setDepartAt(request.departAt()); + dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); + dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); + dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); + dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); + if (StringUtils.hasText(request.status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Dispatch status is PDA-managed and cannot be changed by the edit endpoint"); + } + dispatch.setUpdatedAt(LocalDateTime.now()); + orders.updateById(dispatch); + return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + } + + @Transactional + public void cancel(String customerId, String dispatchNo) { + DispatchOrder dispatch = orders.lockById(customerId, find(customerId, dispatchNo).getId()); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Dispatched order cannot be cancelled"); + } + LocalDateTime now = LocalDateTime.now(); + dispatch.setStatus(CANCELLED); + dispatch.setUpdatedAt(now); + orders.updateById(dispatch); + List activeLocks = locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, customerId) + .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + for (DispatchPalletLock lock : activeLocks) { + lock.setStatus("RELEASED"); + lock.setActiveKey(null); + lock.setReleasedAt(now); + lock.setUpdatedAt(now); + locks.updateById(lock); + } + } + + private DispatchOrder find(String customerId, String dispatchNo) { + DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); + if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + return dispatch; + } + + private DispatchResponse response(DispatchOrder order, List itemList) { + String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) + .distinct().collect(Collectors.joining(",")); + return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), + order.getDriverPhone(), order.getCarrierName(), orderNos, order.getRoute(), + order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), + order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), + order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), + Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0), + Objects.requireNonNullElse(order.getPickedPackageCount(), 0), + Objects.requireNonNullElse(order.getShippedPackageCount(), 0), + Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) + - Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(), + order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(), + item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(), + item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), + item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + Objects.requireNonNullElse(item.getPackageCount(), 0), item.getVolumeCbm(), item.getSourceStage())).toList()); + } + + private static String route(List selected) { + return selected.stream().map(DispatchPalletCandidate::destinationWarehouseCode).filter(StringUtils::hasText) + .distinct().collect(Collectors.joining(" -> ")); + } + + private static List splitOrderRefs(String refs, String fallback) { + String value = StringUtils.hasText(refs) ? refs : fallback; + return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of(); + } + + private static String defaultStatus(String status) { + return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING"; + } + + private static String trimToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java new file mode 100644 index 000000000..d1c3c3667 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/DropshipMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.dropship.mapper") +public class DropshipMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java new file mode 100644 index 000000000..c52914792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/lastleg/dropship") +public class DropshipOrderController { + @Resource private DropshipOrderService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/receive") + public ApiResponse receive(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipRequests.Receive request) { + tokenGuard.verify(token); + return ApiResponse.success(service.receive(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java new file mode 100644 index 000000000..1f16c8c93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public final class DropshipRequests { + private DropshipRequests() {} + public record Receive(@NotBlank String customerId, @NotNull Long orderId, @NotNull Long warehouseId, + @NotBlank String idempotencyKey, @NotEmpty List<@Valid Pallet> pallets) {} + public record Pallet(@NotBlank String palletNo, @NotEmpty List<@Valid SkuPackages> skuPackages) {} + public record SkuPackages(@NotBlank String skuCode, String productName, @Positive long packageCount, + @Positive long productQuantity, + @NotEmpty List<@NotBlank String> cartonMarks) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java new file mode 100644 index 000000000..782f59b26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton") +public class DropshipCarton { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private Long warehousePalletId; + private String palletNo; + private String skuCode; + private String cartonMark; + private Integer cartonSequence; + private String status; + private String outboundOrderNo; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java new file mode 100644 index 000000000..d96b4080a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_carton_ledger") +public class DropshipCartonLedger { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long cartonId; + private String cartonMark; + private String skuCode; + private String operationType; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + private String businessOrderNo; + private String idempotencyKey; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java new file mode 100644 index 000000000..15bc5046a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_order_profile") +public class DropshipOrderProfile { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long warehousePalletId; + private String palletNo; + /** Immutable pallet number snapshot supplied by the first-leg handover event. */ + private String sourcePalletNo; + private Long inventoryPositionId; + private String status; + private String idempotencyKey; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java new file mode 100644 index 000000000..ee7ba183a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.dropship.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data @TableName("ll_dropship_sku_inventory") +public class DropshipSkuInventory { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String customerId; + private Long orderId; + private Long profileId; + private String skuCode; + private String productName; + private Long packageCount; + private Long productQuantity; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java new file mode 100644 index 000000000..8db409367 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +public interface DropshipCartonLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + DropshipCartonLedger findIdempotency(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java new file mode 100644 index 000000000..6c2ad8ed2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipCartonMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1") + DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence") + List listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId); + + @Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java new file mode 100644 index 000000000..01a7d1da2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dropship.mapper; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; +public interface DropshipOrderProfileMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_order_profile WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY source_pallet_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java new file mode 100644 index 000000000..b0f1c7a5a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.dropship.mapper; + +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface DropshipSkuInventoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,sku_code") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java new file mode 100644 index 000000000..6d37fc43f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -0,0 +1,184 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.*; +import com.baiyue.lastleg.dropship.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class DropshipOrderService { + @Resource private LastlegOrderMapper orders; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private DropshipSkuInventoryMapper skuInventory; + @Resource private DropshipCartonMapper cartons; + @Resource private DropshipCartonLedgerMapper ledger; + + @Transactional + public int receive(DropshipRequests.Receive request) { + LastlegOrder order = orders.selectById(request.orderId()); + if (order == null || !request.customerId().equals(order.getCustomerId())) throw conflict("Tail order not found"); + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) throw conflict("Order is not a one-click dropship order"); + long totalPackages = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::packageCount).sum(); + long totalQuantity = request.pallets().stream().flatMap(value -> value.skuPackages().stream()) + .mapToLong(DropshipRequests.SkuPackages::productQuantity).sum(); + if (order.getPackageCount() != null && order.getPackageCount().longValue() != totalPackages) { + throw conflict("SKU package total does not equal order package count"); + } + if (order.getProductQuantity() != null && order.getProductQuantity().longValue() != totalQuantity) { + throw conflict("SKU product quantity total does not equal order product quantity"); + } + List existing = profiles.listOrder(request.customerId(), request.orderId()); + if (!existing.isEmpty()) { + Set existingPallets = existing.stream().map(DropshipOrderProfile::getSourcePalletNo) + .collect(java.util.stream.Collectors.toSet()); + Set requestedPallets = request.pallets().stream().map(value -> value.palletNo().trim()) + .collect(java.util.stream.Collectors.toSet()); + if (!existingPallets.equals(requestedPallets)) throw conflict("Dropship order pallet snapshot conflicts with existing data"); + return Math.toIntExact(totalPackages); + } + + validateMarks(request); + LocalDateTime now = LocalDateTime.now(); + for (DropshipRequests.Pallet inputPallet : request.pallets()) { + DropshipOrderProfile profile = profile(request, order, inputPallet, now); + profiles.insert(profile); + int sequence = 0; + for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) { + DropshipSkuInventory inventory = new DropshipSkuInventory(); + inventory.setId(IdWorker.getId()); inventory.setCustomerId(request.customerId()); inventory.setOrderId(order.getId()); + inventory.setProfileId(profile.getId()); inventory.setSkuCode(input.skuCode()); inventory.setProductName(input.productName()); + inventory.setPackageCount(input.packageCount()); inventory.setProductQuantity(input.productQuantity()); + inventory.setCreatedAt(now); inventory.setUpdatedAt(now); + skuInventory.insert(inventory); + for (String mark : input.cartonMarks()) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId()); + carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null); + carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence); + carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now); + cartons.insert(carton); + ledger.insert(stockInFact(request, order, carton, now)); + } + } + } + return Math.toIntExact(totalPackages); + } + + private void validateMarks(DropshipRequests.Receive request) { + Set marks = new HashSet<>(); + Set palletNos = new HashSet<>(); + for (DropshipRequests.Pallet pallet : request.pallets()) { + if (!palletNos.add(pallet.palletNo().trim())) throw conflict("Duplicate pallet number: " + pallet.palletNo()); + Set skus = new HashSet<>(); + for (DropshipRequests.SkuPackages item : pallet.skuPackages()) { + if (item.packageCount() != item.cartonMarks().size()) { + throw conflict("SKU packageCount must equal cartonMarks count: " + item.skuCode()); + } + if (!skus.add(item.skuCode().trim())) throw conflict("Duplicate SKU on pallet: " + item.skuCode()); + for (String mark : item.cartonMarks()) { + if (!marks.add(mark.trim())) throw conflict("Duplicate carton mark: " + mark); + } + } + } + } + + private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order, + DropshipRequests.Pallet input, LocalDateTime now) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId()); + profile.setSourcePalletNo(input.palletNo().trim()); + profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim()); + profile.setCreatedAt(now); profile.setUpdatedAt(now); + return profile; + } + + @Transactional + public void activateAfterStockIn(String customerId, Long orderId, Long taskId, + List receipts) { + List profilesForOrder = profiles.listOrder(customerId, orderId); + if (profilesForOrder.isEmpty()) return; + if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return; + if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { + throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); + } + Map> receiptsByPallet = receipts.stream() + .filter(receipt -> receipt.getWarehousePalletId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); + if (receiptsByPallet.size() != profilesForOrder.size()) { + throw conflict("PDA pallet count does not match dropship source pallet count"); + } + List orderedProfiles = profilesForOrder.stream() + .sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList(); + List> orderedReceipts = receiptsByPallet.values().stream() + .sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList(); + LocalDateTime now = LocalDateTime.now(); + for (int index = 0; index < orderedProfiles.size(); index++) { + DropshipOrderProfile profile = orderedProfiles.get(index); + List actual = orderedReceipts.get(index); + List profileCartons = cartons.listProfile(customerId, profile.getId()); + long expected = profileCartons.size(); + long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); + if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot"); + Long positionId = actual.get(0).getInventoryPositionId(); + if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) { + throw conflict("Dropship source pallet maps to multiple inventory positions"); + } + InboundStockReceipt first = actual.get(0); + profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo()); + profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1); + profile.setUpdatedAt(now); profiles.updateById(profile); + for (DropshipCarton carton : profileCartons) { + carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo()); + carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now); + cartons.updateById(carton); + DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now); + ledger.insert(activation); + } + } + } + + private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L); + fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order, + DropshipCarton carton, LocalDateTime now) { + DropshipCartonLedger fact = new DropshipCartonLedger(); + fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId()); + fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode()); + fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L); + fact.setBusinessOrderNo(order.getOrderNo()); + fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark()); + fact.setOccurredAt(now); fact.setCreatedAt(now); + return fact; + } + + private static long value(Long value) { return value == null ? 0L : value; } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java new file mode 100644 index 000000000..c73e5fb3b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; + +import java.util.List; + +public record LastlegOrderDetailResponse( + LastlegOrder order, + List cargo, + List statusHistory, + List dropshipPallets +) { + public record DropshipPallet( + Long profileId, + String sourcePalletNo, + Long warehousePalletId, + String warehousePalletNo, + Long inventoryPositionId, + String status, + List skuInventory, + List cartons + ) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java new file mode 100644 index 000000000..712d2b829 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderRequests { + private LastlegOrderRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String orderNo, + String inboundCode, + String sourceOrderNo, + String containerNo, + String status, + String transferWarehouseCode, + Boolean abnormal, + Boolean detained, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record TransferDestination( + @NotEmpty @Size(max = 200) List orderIds, + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + } + + public record SingleTransferDestination( + @NotBlank @Size(max = 128) String warehouseId, + @NotBlank @Size(max = 128) String warehouseCode, + @NotBlank @Size(max = 255) String warehouseName, + @Size(max = 500) String remark + ) { + public TransferDestination withOrderId(long orderId) { + return new TransferDestination(List.of(orderId), warehouseId, warehouseCode, warehouseName, remark); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java new file mode 100644 index 000000000..0ae6357f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Tail-leg order row at inbound-code granularity") +public record LastlegOrderSummary( + Long id, + String orderNo, + String orderType, + String sourceInboundId, + String inboundCode, + String sourceOrderNo, + String customerName, + String overseasWarehouseId, + String overseasWarehouseName, + String transferWarehouseId, + String transferWarehouseCode, + String transferWarehouseName, + String fbaWarehouseCode, + String containerNo, + String containerStatus, + Boolean abnormal, + Boolean detained, + Integer palletCount, + Integer packageCount, + Integer pickedPackageCount, + Integer shippedPackageCount, + Integer remainingPackageCount, + BigDecimal volumeCbm, + Integer productQuantity, + BigDecimal volumeWeightKg, + LocalDateTime estimatedArrivalAt, + LocalDateTime actualArrivalAt, + LocalDateTime estimatedWarehouseAt, + String inboundOrderNo, + String outboundOrderNo, + String deliveryOrderNo, + String relabelOrderNo, + String relabelStatus, + String palletMergeFlag, + String currentStatus, + String subOrderStatus, + String privateRemark, + String customerRemark, + String warehouseRemark, + LocalDateTime createdAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java new file mode 100644 index 000000000..5f6f3b270 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * API request and response models for last-leg delivery. + * / 尾程配送 API 请求与响应模型。 + */ +package com.baiyue.lastleg.dto; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java new file mode 100644 index 000000000..fac02ba0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_event_inbox") public class LastlegEventInbox { @TableId private String eventId; private String customerId; private String eventType; private Integer eventVersion; private String payloadHash; private String payloadJson; private String status; private LocalDateTime receivedAt; private LocalDateTime processedAt; private String lastError; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java new file mode 100644 index 000000000..4337ed4e0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order") +public class LastlegOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String orderNo; + private String customerId; + private String orderType; + private String inboundCode; + private String sourceRegion; + private String sourceInboundId; + private String rootInboundId; + private String sourceOrderId; + private String sourceOrderNo; + private String rootOrderId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseName; + private String fbaWarehouseCode; + private String containerNo; + private String transferWarehouseId; + private String transferWarehouseCode; + private String transferWarehouseName; + private String containerStatus; + private Boolean abnormal; + private Boolean detained; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private BigDecimal volumeCbm; + private Integer productQuantity; + private BigDecimal volumeWeightKg; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime actualArrivalAt; + private LocalDateTime estimatedWarehouseAt; + private String inboundOrderNo; + private String outboundOrderNo; + private String deliveryOrderNo; + private String relabelOrderNo; + private String relabelStatus; + private String palletMergeFlag; + private String currentStatus; + private String subOrderStatus; + private String privateRemark; + private String customerRemark; + private String warehouseRemark; + private String sourceEventId; + private String sourceLoadingPlanId; + private Long sourceLoadingPlanVersion; + private Long sourceHandoverVersion; + private String sourcePayloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java new file mode 100644 index 000000000..557f7d7df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.math.BigDecimal; +@Data @TableName("ll_order_detail") public class LastlegOrderDetail { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String sourceInboundDetailId; private String skuCode; private String productNameZh; private String productNameEn; private Integer packageCount; private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java new file mode 100644 index 000000000..4ca0f92a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.entity; +import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; +@Data @TableName("ll_loading_order_status_history") public class LastlegOrderStatusHistory { @TableId(type=IdType.AUTO) private Long id; private Long orderId; private String fromStatus; private String toStatus; private String eventId; private String remark; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java new file mode 100644 index 000000000..95c097114 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java @@ -0,0 +1,5 @@ +/** + * Persistence entities mapped to last-leg MySQL tables. + * / 映射尾程 MySQL 数据表的持久化实体。 + */ +package com.baiyue.lastleg.entity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java new file mode 100644 index 000000000..a165bf4b2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/InboundPlanMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan({"com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper"}) +public class InboundPlanMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java new file mode 100644 index 000000000..a360ddde1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -0,0 +1,74 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans") +@Tag(name = "Tail-leg inbound plans") +public class InboundPlanController { + @Resource private InboundPlanService service; + @Resource private InboundFbaAppointmentService appointmentService; + + @PostMapping("/convert") + @Operation(summary = "Convert the entire container to one inbound plan") + public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ConvertInboundPlanRequest request) { + return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); + } + + @GetMapping("/{planNo}") + @Operation(summary = "Get an inbound plan with lines, pallets and allocations") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); + } + + @PostMapping("/fba-appointments/batch") + @Operation(summary = "Create one FBA appointment for selected inbound lines") + public ApiResponse batchFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody BatchFbaAppointmentRequest request) { + String customerId = scope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(appointmentService.batchCreate(customerId, operatorId, request)); + } + + @GetMapping("/fba-appointments/{appointmentNo}") + @Operation(summary = "Get an FBA appointment and its selected inbound lines") + public ApiResponse fbaAppointmentDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java new file mode 100644 index 000000000..1572cb552 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public record BatchFbaAppointmentRequest( + String customerId, + @NotEmpty @Size(max = 500) List<@NotNull Long> lineIds, + @NotBlank @Size(max = 64) String appointmentNo, + @NotNull LocalDateTime appointmentAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java new file mode 100644 index 000000000..8d4a6a20b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotNull; + +public record ConvertInboundPlanRequest( + @NotNull Long orderId, + String customerId +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java new file mode 100644 index 000000000..acf32635b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record FbaAppointmentResponse( + Long id, + String customerId, + String appointmentNo, + LocalDateTime appointmentAt, + String appointmentStatus, + String appointmentRemark, + String fbaWarehouseCode, + int selectedCount, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + List items +) { + public record Item(Long id, Long planId, Long lineId, Long orderId, String inboundCode, + String fbaWarehouseCode) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java new file mode 100644 index 000000000..f50f69579 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public record InboundPlanResponse( + Long id, + String planNo, + String customerId, + String containerNo, + String status, + int lineCount, + int palletCount, + BigDecimal totalVolumeCbm, + List lines, + List pallets, + List allocations +) { + public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { + } + + public record Pallet(Long id, String palletNo, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, + Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, + String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + int actualPackageCount) { + } + + public record Allocation(Long lineId, Long palletId, BigDecimal allocatedVolumeCbm, + int plannedPackageCount, int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, String status, int version) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java new file mode 100644 index 000000000..20f34801b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.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_inbound_fba_appointment") +public class InboundFbaAppointment { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String appointmentNo; + private LocalDateTime appointmentAt; + private String appointmentStatus; + private String appointmentRemark; + private String fbaWarehouseCode; + private Integer selectedCount; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java new file mode 100644 index 000000000..96a262753 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.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_inbound_fba_appointment_item") +public class InboundFbaAppointmentItem { + @TableId(type = IdType.INPUT) + private Long id; + private Long appointmentId; + private Long planId; + private Long lineId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java new file mode 100644 index 000000000..a796f8679 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan") +public class InboundPlan { + @TableId(type = IdType.INPUT) + private Long id; + private String planNo; + private String customerId; + private String containerNo; + private String status; + private Integer lineCount; + private Integer palletCount; + private BigDecimal totalVolumeCbm; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java new file mode 100644 index 000000000..070d1805f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_line") +public class InboundPlanLine { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long orderId; + private String inboundCode; + private String fbaWarehouseCode; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal volumeCbm; + private Integer requiredPackageCount; + private Integer plannedPalletCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java new file mode 100644 index 000000000..b998ed6fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_plan_pallet") +public class InboundPlanPallet { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private BigDecimal capacityCbm; + private BigDecimal allocatedVolumeCbm; + private Integer sequenceNo; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long warehouseResourceLockId; + private String assignmentStatus; + private LocalDateTime lockedAt; + private BigDecimal actualVolumeCbm; + private Integer actualPackageCount; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java new file mode 100644 index 000000000..e716cbe98 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@TableName("ll_inbound_plan_pallet_allocation") +public class InboundPlanPalletAllocation { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private Long lineId; + private Long palletId; + private BigDecimal allocatedVolumeCbm; + private Integer plannedPackageCount; + private Integer countedPackageCount; + private Integer pickedPackageCount; + private BigDecimal actualVolumeCbm; + private String status; + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java new file mode 100644 index 000000000..d80900dc6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java new file mode 100644 index 000000000..c94458295 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundFbaAppointmentMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java new file mode 100644 index 000000000..e2972ecba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface InboundFbaAppointmentQueryMapper { + @Select("") + List lockLines(@Param("customerId") String customerId, + @Param("lineIds") List lineIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java new file mode 100644 index 000000000..d7480e0d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanLineMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java new file mode 100644 index 000000000..893df0507 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPlanMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1") + InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java new file mode 100644 index 000000000..ed66ee48d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface InboundPlanOrderMapper { + @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); + + @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + + "COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) AS destinationCount," + + "SUM(CASE WHEN d.id IS NOT NULL " + + "AND COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL " + + "AND COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#') " + + "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + + "AS destinationMismatchCount " + + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + + "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "ORDER BY o.id FOR UPDATE") + List lockContainerOrders(@Param("customerId") String customerId, + @Param("containerNo") String containerNo); + + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId}") + int markPlanned(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("updatedAt") LocalDateTime updatedAt); + + @Insert("INSERT INTO ll_loading_order_status_history(order_id,from_status,to_status,remark,created_at) " + + "VALUES(#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") + int insertHistory(@Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, + @Param("remark") String remark, @Param("createdAt") LocalDateTime createdAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java new file mode 100644 index 000000000..a2c825b35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletAllocationMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java new file mode 100644 index 000000000..c700504e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPlanPalletMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java new file mode 100644 index 000000000..2244c4d85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.model; + +public record FbaAppointmentLineSnapshot( + Long lineId, + Long planId, + Long orderId, + String inboundCode, + String fbaWarehouseCode +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java new file mode 100644 index 000000000..99644373d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.inboundplan.model; + +public enum FbaAppointmentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java new file mode 100644 index 000000000..b4d7d5886 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.model; + +import java.math.BigDecimal; + +public record SourceOrderSnapshot( + Long orderId, + String customerId, + String containerNo, + String inboundCode, + String currentStatus, + String inboundOrderNo, + BigDecimal volumeCbm, + int packageCount, + String fbaWarehouseCode, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + int destinationCount, + int destinationMismatchCount +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, null, destinationWarehouseId, destinationWarehouseCode, + destinationWarehouseName, destinationCount, destinationMismatchCount); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java new file mode 100644 index 000000000..f024f8efd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets") +public class InboundPalletAssignmentController { + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/{planPalletId}/assign") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, @PathVariable long planPalletId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.Assign request) { + service.assign(scope(user, customerId), planNo, planPalletId, request); + return ApiResponse.success(null); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java new file mode 100644 index 000000000..7ade7f61a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -0,0 +1,107 @@ +package com.baiyue.lastleg.inboundplan.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda") +public class InboundPdaController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + @Resource private InboundPdaWorkflowService service; + + @PostMapping("/push") + public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.Push request) { + requireOperate(user); + return ApiResponse.success(service.push(scope(user, customerId), planNo, request)); + } + + @GetMapping + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.task(scope(user, customerId), planNo)); + } + + @PostMapping("/allocations/{allocationId}/count") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + requireOperate(user); + return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request)); + } + + @PostMapping("/allocations/{allocationId}/pick") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.PackageOperation request) { + requireOperate(user); + return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); + } + + @GetMapping("/allocations/{allocationId}/merge-candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @PathVariable long allocationId, @RequestParam(required = false) String customerId) { + return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId)); + } + + @PostMapping("/allocations/{allocationId}/merge") + public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @PathVariable long allocationId, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.ConfirmMerge request) { + requireOperate(user); + return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request)); + } + + @PostMapping("/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId) { + requireOperate(user); + return ApiResponse.success(service.complete(scope(user, customerId), planNo)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } + + private void requireOperate(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java new file mode 100644 index 000000000..3a1d3880b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class PdaRequests { + private PdaRequests() {} + + public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record Assign(@Positive long warehouseId, @Positive long palletId, @Positive long locationId, + @NotBlank String idempotencyKey) {} + + public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java new file mode 100644 index 000000000..0a9b26291 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inboundplan.pda.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaResponses { + private PdaResponses() {} + + public record Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {} + + public record Operation(String result, long allocationId, int requiredPackageCount, + int countedPackageCount, int pickedPackageCount, + BigDecimal actualVolumeCbm, Integer remainingPackageCount, + Integer requestedPackageCount, Integer mergePackageCount, + List candidates) {} + + public record MergeCandidate(long allocationId, long palletId, String palletNo, long lineId, + long orderId, String inboundCode, int availablePackageCount, + BigDecimal availableVolumeCbm) {} + + public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, + int movedPackageCount, BigDecimal movedVolumeCbm, + boolean sourcePalletScrapped) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java new file mode 100644 index 000000000..0e70e49bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.pda.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_inbound_pallet_merge") +public class InboundPalletMerge { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private String mergeGroupNo; + private Long sourcePalletId; + private Long targetPalletId; + private Long sourceAllocationId; + private Long targetAllocationId; + private Long sourceLineId; + private Long targetLineId; + private Long sourceOrderId; + private Long targetOrderId; + private Integer movedPackageCount; + private BigDecimal movedVolumeCbm; + private Boolean sourcePalletScrapped; + private String status; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java new file mode 100644 index 000000000..2a95ef792 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.inboundplan.pda.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_inbound_pda_operation") +public class InboundPdaOperation { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long taskId; + private Long allocationId; + private String action; + private String idempotencyKey; + private String requestHash; + private String requestJson; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java new file mode 100644 index 000000000..753cd2fe2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan.pda.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_inbound_pda_task") +public class InboundPdaTask { + @TableId(type = IdType.INPUT) + private Long id; + private Long planId; + private String customerId; + private String taskNo; + private String status; + private String operatorId; + private String operatorName; + private LocalDateTime pushedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Integer version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java new file mode 100644 index 000000000..814967824 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inboundplan.pda.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; + +/** Immutable inbound-order/pallet package receipt created when a PDA task completes. */ +@Data +@TableName("ll_inbound_stock_receipt") +public class InboundStockReceipt { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long planId; + private Long taskId; + private Long allocationId; + private Long lineId; + private Long orderId; + private String inboundCode; + private Long warehousePalletId; + private String palletNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private String batchNo; + private Long receivedPackages; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private String inventoryOperationKey; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} 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 new file mode 100644 index 000000000..1a5ebcf0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.util.Map; + +@Component +public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { + @Resource private RestClient.Builder restClientBuilder; + + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") + private String baseUrl; + + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; + + private RestClient client; + + @PostConstruct + void initializeClient() { + this.client = restClientBuilder.baseUrl(baseUrl).build(); + } + + @Override + public WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId) { + WarehousePalletResponse response = client.post().uri("/internal/v1/warehouse-pallets/ensure-dropship") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, "businessId", businessId)) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected dropship pallet: HTTP " + result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty pallet response"); + return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().locationId()); + } + + @Override + public long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey) { + ReserveEnvelope response = client.post() + .uri("/internal/v1/warehouse-resource-locks") + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId), "warehouseId", warehouseId, + "palletId", palletId, "locationId", locationId, "businessType", businessType, + "businessId", businessId, "reservationKey", reservationKey)) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet reservation: HTTP " + result.getStatusCode()); + }) + .body(ReserveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned an empty lock response"); + return response.data().lockId(); + } + + @Override + public void scrap(String customerId, long resourceLockId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/{lockId}/scrap", resourceLockId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + @Override + public void scrapPallet(String customerId, long palletId) { + client.post() + .uri("/internal/v1/warehouse-resource-locks/pallets/{palletId}/scrap", palletId) + .header("X-Internal-Service-Token", internalToken) + .body(Map.of("customerId", Long.parseLong(customerId))) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new IllegalStateException("Warehouse rejected pallet scrap: HTTP " + response.getStatusCode()); + }) + .toBodilessEntity(); + } + + private record WarehousePalletResponse(WarehousePalletData data) {} + private record WarehousePalletData(long id, String palletCode, long locationId) {} + private record ReserveEnvelope(ReserveData data) {} + private record ReserveData(long lockId) {} +} 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 new file mode 100644 index 000000000..ada468ece --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +public interface WarehouseResourceGateway { + WarehousePalletReference ensureDropshipPallet(String customerId, long warehouseId, String businessId); + long reserve(String customerId, long warehouseId, long palletId, long locationId, + String businessType, String businessId, String reservationKey); + void scrap(String customerId, long resourceLockId); + void scrapPallet(String customerId, long palletId); + + record WarehousePalletReference(long palletId, String palletNo, long locationId) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java new file mode 100644 index 000000000..515ce95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface InboundPalletMergeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java new file mode 100644 index 000000000..ac27ae96e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_operation WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + InboundPdaOperation findByKey(@Param("customerId") String customerId, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java new file mode 100644 index 000000000..b144e7d55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPdaTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1 FOR UPDATE") + InboundPdaTask lockByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); + + @Select("SELECT * FROM ll_inbound_pda_task WHERE customer_id=#{customerId} AND plan_id=#{planId} LIMIT 1") + InboundPdaTask findByPlan(@Param("customerId") String customerId, @Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java new file mode 100644 index 000000000..bcde3f936 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InboundStockReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_inbound_stock_receipt WHERE customer_id=#{customerId} AND task_id=#{taskId} " + + "ORDER BY allocation_id") + List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java new file mode 100644 index 000000000..e39b826e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +import java.util.List; + +@Repository +public class PdaWorkflowRepository { + @Resource private InboundPlanMapper plans; + @Resource private InboundPlanLineMapper lines; + @Resource private InboundPlanPalletMapper pallets; + @Resource private InboundPlanPalletAllocationMapper allocations; + @Resource private InboundPdaTaskMapper tasks; + @Resource private InboundPdaOperationMapper operations; + @Resource private InboundPalletMergeMapper merges; + + public InboundPlan lockPlan(String customerId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + } + + public InboundPdaTask lockTask(String customerId, Long planId) { return tasks.lockByPlan(customerId, planId); } + public InboundPdaTask task(String customerId, Long planId) { return tasks.findByPlan(customerId, planId); } + public InboundPdaOperation operation(String customerId, String key) { return operations.findByKey(customerId, key); } + + public InboundPlanPalletAllocation lockAllocation(Long planId, Long allocationId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId) + .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); + } + + public InboundPlanPallet lockPallet(Long planId, Long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); + } + + public InboundPlanLine line(Long planId, Long lineId) { + return lines.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, planId).eq(InboundPlanLine::getId, lineId)); + } + + public List lines(Long planId) { + return lines.selectList(new LambdaQueryWrapper().eq(InboundPlanLine::getPlanId, planId)); + } + + public List pallets(Long planId) { + return pallets.selectList(new LambdaQueryWrapper().eq(InboundPlanPallet::getPlanId, planId)); + } + + public List allocations(Long planId) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId)); + } + + public void insert(InboundPdaTask value) { tasks.insert(value); } + public void insert(InboundPdaOperation value) { operations.insert(value); } + public void insert(InboundPalletMerge value) { merges.insert(value); } + public void insert(InboundPlanPalletAllocation value) { allocations.insert(value); } + public void update(InboundPlan value) { plans.updateById(value); } + public void update(InboundPdaTask value) { tasks.updateById(value); } + public void update(InboundPlanPallet value) { pallets.updateById(value); } + public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java new file mode 100644 index 000000000..680e43d57 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -0,0 +1,532 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class InboundPdaWorkflowService { + private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); + + @Resource private PdaWorkflowRepository repository; + @Resource private WarehouseResourceGateway warehouse; + @Resource private InboundStockReceiptService stockReceipts; + @Resource private ObjectMapper sourceObjectMapper; + + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + this.objectMapper = sourceObjectMapper.copy() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional + public PdaResponses.Task push(String customerId, String planNo, PdaRequests.Push request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PUSH", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + + List pallets = repository.pallets(plan.getId()); + if (pallets.isEmpty() || pallets.stream().anyMatch(this::isNotAssigned)) { + throw conflict("Every planned pallet must have an assigned and locked warehouse pallet/location"); + } + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + LocalDateTime now = LocalDateTime.now(); + if (task == null) { + task = new InboundPdaTask(); + task.setId(IdWorker.getId()); + task.setPlanId(plan.getId()); + task.setCustomerId(customerId); + task.setTaskNo("PDA" + task.getId()); + task.setStatus("PDA_PENDING"); + task.setOperatorId(request.operatorId()); + task.setOperatorName(request.operatorName()); + task.setPushedAt(now); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setVersion(0); + repository.insert(task); + } + plan.setStatus("PDA_PENDING"); + plan.setUpdatedAt(now); + repository.update(plan); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "PUSH", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public void assign(String customerId, String planNo, long planPalletId, PdaRequests.Assign request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); + validateDestinationWarehouse(pallet, request.warehouseId()); + if (pallet.getWarehouseResourceLockId() != null) { + if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) + && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; + throw conflict("Planned pallet is already assigned to another warehouse resource"); + } + String businessId = planNo + ":" + planPalletId; + long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(), + "INBOUND_PLAN_PALLET", businessId, request.idempotencyKey()); + pallet.setWarehousePalletId(request.palletId()); + pallet.setWarehouseLocationId(request.locationId()); + pallet.setWarehouseResourceLockId(lockId); + pallet.setAssignmentStatus("ASSIGNED"); + pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + List pallets = repository.pallets(plan.getId()); + if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + } + + @Transactional + public PdaResponses.Task task(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = repository.task(customerId, plan.getId()); + if (task == null) throw notFound("PDA task not found"); + return task(task, planNo); + } + + @Transactional + public PdaResponses.Operation count(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + int picked = value(allocation.getPickedPackageCount()); + if (request.packageCount() < picked) { + throw conflict("Counted package count cannot be less than already picked packages"); + } + if (request.packageCount() > value(allocation.getPlannedPackageCount())) { + throw conflict("Counted package count cannot exceed this pallet allocation"); + } + allocation.setCountedPackageCount(request.packageCount()); + allocation.setStatus(request.packageCount() == value(allocation.getPlannedPackageCount()) + ? "COUNTED" : "COUNTING"); + bump(allocation); + repository.update(allocation); + startPicking(task, plan, request.operatorId(), request.operatorName()); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + PdaResponses.Operation result = operation("COUNTED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "COUNT", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, + PdaRequests.PackageOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int picked = value(allocation.getPickedPackageCount()); + int countedRemaining = value(allocation.getCountedPackageCount()) - picked; + if (countedRemaining <= 0) throw conflict("Count this pallet allocation before picking"); + int plannedRemaining = value(allocation.getPlannedPackageCount()) - picked; + if (request.packageCount() > plannedRemaining) { + int mergeCount = request.packageCount() - Math.max(0, plannedRemaining); + List candidates = candidates(plan, allocation, mergeCount); + PdaResponses.Operation result = operation("MERGE_CONFIRMATION_REQUIRED", allocation, line, + candidates, plannedRemaining, request.packageCount(), mergeCount); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + if (request.packageCount() > countedRemaining) { + throw conflict("Picked package count cannot exceed the counted remaining packages"); + } + BigDecimal perPackage = perPackageVolume(line); + int newPicked = picked + request.packageCount(); + allocation.setPickedPackageCount(newPicked); + allocation.setActualVolumeCbm(perPackage.multiply(BigDecimal.valueOf(newPicked)).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(newPicked == value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + request.packageCount()); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add( + perPackage.multiply(BigDecimal.valueOf(request.packageCount()))).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("PICKED", allocation, line, List.of(), null, null, null); + saveOperation(customerId, task.getId(), allocationId, "PICK", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public List mergeCandidates(String customerId, String planNo, + long allocationId) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + return candidates(plan, allocation, 1); + } + + @Transactional + public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId, + PdaRequests.ConfirmMerge request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Merge.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + if (request.sourceAllocationId() == targetAllocationId) throw conflict("Source and target allocations differ"); + InboundPlanPalletAllocation target = requireAllocation(plan.getId(), targetAllocationId); + InboundPlanPalletAllocation source = requireAllocation(plan.getId(), request.sourceAllocationId()); + InboundPlanPallet targetPallet = requirePallet(plan.getId(), target.getPalletId()); + InboundPlanPallet sourcePallet = requirePallet(plan.getId(), source.getPalletId()); + if (Objects.equals(targetPallet.getId(), sourcePallet.getId())) throw conflict("Source and target pallets differ"); + if (!sameWarehouse(targetPallet, sourcePallet)) throw conflict("Pallet merge requires the same destination warehouse"); + if (value(source.getPickedPackageCount()) < request.packageCount()) { + throw conflict("Source pallet does not contain enough picked packages"); + } + InboundPlanLine sourceLine = requireLine(plan.getId(), source.getLineId()); + InboundPlanLine targetLine = requireLine(plan.getId(), target.getLineId()); + BigDecimal movedVolume = perPackageVolume(sourceLine) + .multiply(BigDecimal.valueOf(request.packageCount())).setScale(4, RoundingMode.HALF_UP); + if (value(sourcePallet.getActualPackageCount()) < request.packageCount()) { + throw conflict("Source pallet actual package count is insufficient"); + } + if (targetPallet.getCapacityCbm() != null + && volume(targetPallet.getActualVolumeCbm()).add(movedVolume).compareTo(targetPallet.getCapacityCbm()) > 0) { + throw conflict("Merged volume exceeds target pallet capacity"); + } + + moveSourceAllocation(plan.getId(), source, targetPallet.getId(), request.packageCount(), movedVolume); + sourcePallet.setActualPackageCount(value(sourcePallet.getActualPackageCount()) - request.packageCount()); + sourcePallet.setActualVolumeCbm(nonNegative(volume(sourcePallet.getActualVolumeCbm()).subtract(movedVolume))); + targetPallet.setActualPackageCount(value(targetPallet.getActualPackageCount()) + request.packageCount()); + targetPallet.setActualVolumeCbm(volume(targetPallet.getActualVolumeCbm()).add(movedVolume)); + boolean scrapped = sourcePallet.getActualPackageCount() == 0; + if (scrapped) { + if (sourcePallet.getWarehouseResourceLockId() == null) { + throw conflict("Source pallet has no warehouse resource lock"); + } + warehouse.scrap(customerId, sourcePallet.getWarehouseResourceLockId()); + sourcePallet.setAssignmentStatus("SCRAPPED"); + sourcePallet.setWarehouseLocationId(null); + sourcePallet.setWarehouseResourceLockId(null); + } + repository.update(sourcePallet); + repository.update(targetPallet); + + String groupNo = "PMG" + IdWorker.getId(); + InboundPalletMerge merge = new InboundPalletMerge(); + merge.setId(IdWorker.getId()); + merge.setCustomerId(customerId); + merge.setTaskId(task.getId()); + merge.setMergeGroupNo(groupNo); + merge.setSourcePalletId(sourcePallet.getId()); + merge.setTargetPalletId(targetPallet.getId()); + merge.setSourceAllocationId(source.getId()); + merge.setTargetAllocationId(target.getId()); + merge.setSourceLineId(sourceLine.getId()); + merge.setTargetLineId(targetLine.getId()); + merge.setSourceOrderId(sourceLine.getOrderId()); + merge.setTargetOrderId(targetLine.getOrderId()); + merge.setMovedPackageCount(request.packageCount()); + merge.setMovedVolumeCbm(movedVolume); + merge.setSourcePalletScrapped(scrapped); + merge.setStatus("CONFIRMED"); + merge.setCreatedAt(LocalDateTime.now()); + repository.insert(merge); + PdaResponses.Merge result = new PdaResponses.Merge("MERGED", groupNo, sourceLine.getOrderId(), + targetLine.getOrderId(), request.packageCount(), movedVolume, scrapped); + saveOperation(customerId, task.getId(), targetAllocationId, "MERGE", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Task complete(String customerId, String planNo) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + Map pickedByLine = new HashMap<>(); + List allocations = repository.allocations(plan.getId()); + for (InboundPlanPalletAllocation allocation : allocations) { + pickedByLine.merge(allocation.getLineId(), value(allocation.getPickedPackageCount()), Integer::sum); + } + List lines = repository.lines(plan.getId()); + if (lines.isEmpty()) throw conflict("Inbound plan has no lines"); + for (InboundPlanLine line : lines) { + if (pickedByLine.getOrDefault(line.getId(), 0) != value(line.getRequiredPackageCount())) { + throw conflict("All line picked package totals must exactly equal required package totals"); + } + } + LocalDateTime now = LocalDateTime.now(); + stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); + task.setStatus("COMPLETED"); + task.setCompletedAt(now); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("COMPLETED"); + plan.setUpdatedAt(now); + repository.update(plan); + return task(task, planNo); + } + + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, + int packageCount, BigDecimal movedVolume) { + source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); + source.setCountedPackageCount(Math.max(source.getPickedPackageCount(), + value(source.getCountedPackageCount()) - packageCount)); + source.setActualVolumeCbm(nonNegative(volume(source.getActualVolumeCbm()).subtract(movedVolume))); + source.setStatus(source.getPickedPackageCount() == 0 ? "MERGED_OUT" : "PICKED"); + bump(source); + repository.update(source); + + InboundPlanPalletAllocation moved = repository.allocations(planId).stream() + .filter(value -> Objects.equals(value.getLineId(), source.getLineId())) + .filter(value -> Objects.equals(value.getPalletId(), targetPalletId)) + .findFirst().orElse(null); + if (moved == null) { + moved = new InboundPlanPalletAllocation(); + moved.setId(IdWorker.getId()); + moved.setPlanId(planId); + moved.setLineId(source.getLineId()); + moved.setPalletId(targetPalletId); + moved.setAllocatedVolumeCbm(ZERO_VOLUME); + moved.setPlannedPackageCount(0); + moved.setCountedPackageCount(packageCount); + moved.setPickedPackageCount(packageCount); + moved.setActualVolumeCbm(movedVolume); + moved.setStatus("MERGED_IN"); + moved.setVersion(0); + repository.insert(moved); + } else { + moved.setCountedPackageCount(value(moved.getCountedPackageCount()) + packageCount); + moved.setPickedPackageCount(value(moved.getPickedPackageCount()) + packageCount); + moved.setActualVolumeCbm(volume(moved.getActualVolumeCbm()).add(movedVolume)); + moved.setStatus("MERGED_IN"); + bump(moved); + repository.update(moved); + } + } + + private List candidates(InboundPlan plan, + InboundPlanPalletAllocation target, + int needed) { + Map palletById = new HashMap<>(); + for (InboundPlanPallet pallet : repository.pallets(plan.getId())) palletById.put(pallet.getId(), pallet); + Map lineById = new HashMap<>(); + for (InboundPlanLine line : repository.lines(plan.getId())) lineById.put(line.getId(), line); + InboundPlanPallet targetPallet = palletById.get(target.getPalletId()); + if (targetPallet == null) throw notFound("Target pallet not found"); + List result = new ArrayList<>(); + for (InboundPlanPalletAllocation candidate : repository.allocations(plan.getId())) { + if (Objects.equals(candidate.getPalletId(), target.getPalletId())) continue; + int available = value(candidate.getPickedPackageCount()); + if (available <= 0) continue; + InboundPlanPallet pallet = palletById.get(candidate.getPalletId()); + InboundPlanLine line = lineById.get(candidate.getLineId()); + if (pallet == null || line == null || !sameWarehouse(targetPallet, pallet)) continue; + if (targetPallet.getCapacityCbm() != null) { + BigDecimal remainingVolume = targetPallet.getCapacityCbm() + .subtract(volume(targetPallet.getActualVolumeCbm())); + BigDecimal packageVolume = perPackageVolume(line); + if (packageVolume.signum() > 0) { + int capacityPackages = remainingVolume.signum() <= 0 ? 0 + : remainingVolume.divide(packageVolume, 0, RoundingMode.DOWN).intValue(); + available = Math.min(available, capacityPackages); + if (available <= 0) continue; + } + } + result.add(new PdaResponses.MergeCandidate(candidate.getId(), pallet.getId(), pallet.getPalletNo(), + line.getId(), line.getOrderId(), line.getInboundCode(), available, + perPackageVolume(line).multiply(BigDecimal.valueOf(available)).setScale(4, RoundingMode.HALF_UP))); + } + result.sort(Comparator.comparingInt(PdaResponses.MergeCandidate::availablePackageCount).reversed()); + return result; + } + + private boolean sameWarehouse(InboundPlanPallet left, InboundPlanPallet right) { + String leftKey = left.getDestinationWarehouseId() != null + ? left.getDestinationWarehouseId() : left.getDestinationWarehouseCode(); + String rightKey = right.getDestinationWarehouseId() != null + ? right.getDestinationWarehouseId() : right.getDestinationWarehouseCode(); + return leftKey != null && leftKey.equals(rightKey); + } + + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { + String destinationId = pallet.getDestinationWarehouseId(); + if (destinationId == null || destinationId.isBlank()) return; + try { + if (Long.parseLong(destinationId) != warehouseId) { + throw conflict("Warehouse resource must belong to the planned destination warehouse"); + } + } catch (NumberFormatException exception) { + throw conflict("Planned destination warehouse ID is not a numeric warehouse resource ID"); + } + } + + private void startPicking(InboundPdaTask task, InboundPlan plan, String operatorId, String operatorName) { + LocalDateTime now = LocalDateTime.now(); + if (task.getStartedAt() == null) task.setStartedAt(now); + task.setStatus("PICKING"); + task.setOperatorId(operatorId); + task.setOperatorName(operatorName); + task.setUpdatedAt(now); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + plan.setStatus("PICKING"); + plan.setUpdatedAt(now); + repository.update(plan); + } + + private boolean isNotAssigned(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null + || pallet.getWarehouseResourceLockId() == null + || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); + } + + private InboundPlan requirePlan(String customerId, String planNo) { + InboundPlan plan = repository.lockPlan(customerId, planNo); + if (plan == null) throw notFound("Inbound plan not found"); + return plan; + } + + private InboundPdaTask requireActiveTask(String customerId, InboundPlan plan) { + InboundPdaTask task = repository.lockTask(customerId, plan.getId()); + if (task == null) throw conflict("Push the inbound plan to PDA before operating"); + if ("COMPLETED".equals(task.getStatus())) throw conflict("PDA task is already completed"); + return task; + } + + private InboundPlanPalletAllocation requireAllocation(Long planId, Long id) { + InboundPlanPalletAllocation value = repository.lockAllocation(planId, id); + if (value == null) throw notFound("Pallet allocation not found"); + return value; + } + + private InboundPlanPallet requirePallet(Long planId, Long id) { + InboundPlanPallet value = repository.lockPallet(planId, id); + if (value == null) throw notFound("Planned pallet not found"); + return value; + } + + private InboundPlanLine requireLine(Long planId, Long id) { + InboundPlanLine value = repository.line(planId, id); + if (value == null) throw notFound("Inbound plan line not found"); + return value; + } + + private PdaResponses.Operation operation(String result, InboundPlanPalletAllocation allocation, + InboundPlanLine line, + List candidates, + Integer remaining, Integer requested, Integer merge) { + return new PdaResponses.Operation(result, allocation.getId(), value(line.getRequiredPackageCount()), + value(allocation.getCountedPackageCount()), value(allocation.getPickedPackageCount()), + volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt()); + } + + private BigDecimal perPackageVolume(InboundPlanLine line) { + int required = value(line.getRequiredPackageCount()); + if (required <= 0) throw conflict("Inbound line required package count must be positive"); + return volume(line.getVolumeCbm()).divide(BigDecimal.valueOf(required), 8, RoundingMode.HALF_UP); + } + + private InboundPdaOperation replay(String customerId, String action, String key, Object request) { + InboundPdaOperation operation = repository.operation(customerId, key); + if (operation == null) return null; + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different request"); + } + return operation; + } + + private void saveOperation(String customerId, Long taskId, Long allocationId, String action, + String key, Object request, Object result) { + InboundPdaOperation operation = new InboundPdaOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setTaskId(taskId); + operation.setAllocationId(allocationId); + operation.setAction(action); + operation.setIdempotencyKey(key); + operation.setRequestHash(hash(request)); + operation.setRequestJson(write(request)); + operation.setResultJson(write(result)); + operation.setCreatedAt(LocalDateTime.now()); + repository.insert(operation); + } + + private String hash(Object value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize PDA operation", exception); + } + } + + private T read(InboundPdaOperation operation, Class type) { + try { + return objectMapper.readValue(operation.getResultJson(), type); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read persisted PDA operation result", exception); + } + } + + private void bump(InboundPlanPalletAllocation allocation) { + allocation.setVersion(value(allocation.getVersion()) + 1); + } + + private static int value(Integer value) { return value == null ? 0 : value; } + private static BigDecimal volume(BigDecimal value) { return value == null ? ZERO_VOLUME : value; } + private static BigDecimal nonNegative(BigDecimal value) { return value.signum() < 0 ? ZERO_VOLUME : value.setScale(4, RoundingMode.HALF_UP); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java new file mode 100644 index 000000000..a2ec49f0f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -0,0 +1,193 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Converts final PDA package counts into inbound-order/pallet inventory. */ +@Service +public class InboundStockReceiptService { + @Resource private LastlegOrderMapper orders; + @Resource private InboundStockReceiptMapper receipts; + @Resource private InventoryCommandService inventory; + @Resource private DropshipOrderService dropshipOrders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + + @Transactional + public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task, + List lines, List allocations, + List pallets, LocalDateTime receivedAt) { + Map orderById = orders.selectBatchIds(lines.stream() + .map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream() + .filter(order -> customerId.equals(order.getCustomerId())) + .collect(Collectors.toMap(LastlegOrder::getId, order -> order)); + Map palletById = pallets.stream() + .collect(Collectors.toMap(InboundPlanPallet::getId, pallet -> pallet)); + Map> allocationsByLine = allocations.stream() + .filter(allocation -> value(allocation.getPickedPackageCount()) > 0) + .sorted(Comparator.comparing(InboundPlanPalletAllocation::getId)) + .collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId)); + + Set expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream) + .map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet()); + List existing = receipts.listTask(customerId, task.getId()); + if (!existing.isEmpty()) { + Set receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (receivedAllocationIds.equals(expectedAllocationIds)) { + activateDropship(customerId, task.getId(), orderById, existing); + completeOrders(customerId, plan, orderById, receivedAt); + return; + } + throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying"); + } + + List created = new java.util.ArrayList<>(); + for (InboundPlanLine line : lines) { + LastlegOrder order = orderById.get(line.getOrderId()); + if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant"); + List lineAllocations = allocationsByLine + .getOrDefault(line.getId(), List.of()); + long pickedPackages = lineAllocations.stream() + .mapToLong(allocation -> value(allocation.getPickedPackageCount())).sum(); + if (pickedPackages != value(line.getRequiredPackageCount())) { + throw conflict("PDA picked packages must equal the inbound order package total"); + } + for (InboundPlanPalletAllocation allocation : lineAllocations) { + created.add(persist(customerId, plan, task, line, allocation, + requirePallet(palletById, allocation), order, receivedAt)); + } + } + activateDropship(customerId, task.getId(), orderById, created); + completeOrders(customerId, plan, orderById, receivedAt); + } + + private void completeOrders(String customerId, InboundPlan plan, Map orderById, + LocalDateTime completedAt) { + for (LastlegOrder snapshot : orderById.values()) { + LastlegOrder order = orders.lockById(customerId, snapshot.getId()); + if (order == null) throw conflict("Inbound tail-leg order disappeared while completing stock-in"); + if ("INBOUND_COMPLETED".equals(order.getCurrentStatus())) continue; + String before = order.getCurrentStatus(); + order.setCurrentStatus("INBOUND_COMPLETED"); + order.setSubOrderStatus("INBOUND_PLAN:COMPLETED"); + order.setUpdatedAt(completedAt); + orders.updateById(order); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); + history.setFromStatus(before); + history.setToStatus("INBOUND_COMPLETED"); + history.setEventId("PDA_INBOUND_COMPLETE:" + plan.getPlanNo()); + history.setRemark("PDA inbound completed and inventory stocked in"); + history.setCreatedAt(completedAt); + orderHistories.insert(history); + } + } + + private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, + InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, + LastlegOrder order, LocalDateTime receivedAt) { + long packages = value(allocation.getPickedPackageCount()); + if (packages <= 0 || pallet.getWarehousePalletId() == null) { + throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); + } + String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), + "Destination warehouse code is required for stock-in"); + String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId(); + String overseasWarehouseId = required(order.getOverseasWarehouseId(), + "Actual overseas warehouse is required for stock-in"); + InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), + new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, + null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, + line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), + pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), + pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), + order.getSourceEventId(), operationKey, + "PDA inbound completion for " + line.getInboundCode())); + + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(IdWorker.getId()); + receipt.setCustomerId(customerId); + receipt.setPlanId(plan.getId()); + receipt.setTaskId(task.getId()); + receipt.setAllocationId(allocation.getId()); + receipt.setLineId(line.getId()); + receipt.setOrderId(order.getId()); + receipt.setInboundCode(line.getInboundCode()); + receipt.setWarehousePalletId(pallet.getWarehousePalletId()); + receipt.setPalletNo(pallet.getPalletNo()); + receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setBatchNo(plan.getPlanNo()); + receipt.setReceivedPackages(packages); + receipt.setInventoryBalanceId(result.balanceId()); + receipt.setInventoryPositionId(result.positionId()); + receipt.setInventoryOperationKey(operationKey); + receipt.setReceivedAt(receivedAt); + receipt.setCreatedAt(receivedAt); + receipts.insert(receipt); + return receipt; + } + + private void activateDropship(String customerId, Long taskId, Map orderById, + List stockReceipts) { + Map> receiptsByOrder = stockReceipts.stream() + .filter(receipt -> receipt.getOrderId() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getOrderId)); + for (LastlegOrder order : orderById.values()) { + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId, + receiptsByOrder.getOrDefault(order.getId(), List.of())); + } + } + } + + private static InboundPlanPallet requirePallet(Map pallets, + InboundPlanPalletAllocation allocation) { + InboundPlanPallet pallet = pallets.get(allocation.getPalletId()); + if (pallet == null) throw conflict("PDA allocation pallet is missing"); + return pallet; + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw conflict(message); + return normalized; + } + + private static String first(String first, String second) { + String normalized = trim(first); + return normalized == null ? trim(second) : normalized; + } + + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static long value(Integer value) { return value == null ? 0L : value.longValue(); } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java new file mode 100644 index 000000000..6045f4a46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentStatus; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +@Service +public class InboundFbaAppointmentService { + @Resource private InboundFbaAppointmentMapper appointmentMapper; + @Resource private InboundFbaAppointmentItemMapper itemMapper; + @Resource private InboundFbaAppointmentQueryMapper queryMapper; + + @Transactional + public FbaAppointmentResponse batchCreate(String customerId, String operatorId, + BatchFbaAppointmentRequest request) { + List lineIds = List.copyOf(request.lineIds()); + if (new HashSet<>(lineIds).size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected inbound lines contain duplicates"); + } + String appointmentNo = requiredTrimmed(request.appointmentNo(), "Appointment number is required"); + String status = normalizeStatus(request.appointmentStatus()); + String remark = trimToNull(request.appointmentRemark()); + + InboundFbaAppointment existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + List lines = queryMapper.lockLines(customerId, lineIds); + if (lines.size() != lineIds.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "One or more selected inbound lines do not exist in this tenant"); + } + String fbaWarehouseCode = commonFbaWarehouse(lines); + + // The row locks serialize concurrent submissions selecting the same inbound lines. + existing = find(customerId, appointmentNo); + if (existing != null) { + return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + } + + LocalDateTime now = LocalDateTime.now(); + InboundFbaAppointment appointment = new InboundFbaAppointment(); + appointment.setId(IdWorker.getId()); + appointment.setCustomerId(customerId); + appointment.setAppointmentNo(appointmentNo); + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setAppointmentStatus(status); + appointment.setAppointmentRemark(remark); + appointment.setFbaWarehouseCode(fbaWarehouseCode); + appointment.setSelectedCount(lines.size()); + appointment.setCreatedBy(trimToNull(operatorId)); + appointment.setCreatedAt(now); + appointment.setUpdatedAt(now); + try { + appointmentMapper.insert(appointment); + } catch (DuplicateKeyException exception) { + InboundFbaAppointment concurrent = find(customerId, appointmentNo); + if (concurrent != null) { + return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark); + } + throw exception; + } + + for (FbaAppointmentLineSnapshot line : lines) { + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setId(IdWorker.getId()); + item.setAppointmentId(appointment.getId()); + item.setPlanId(line.planId()); + item.setLineId(line.lineId()); + item.setOrderId(line.orderId()); + item.setInboundCode(line.inboundCode()); + item.setFbaWarehouseCode(line.fbaWarehouseCode()); + item.setCreatedAt(now); + itemMapper.insert(item); + } + return response(appointment, items(appointment.getId())); + } + + @Transactional(readOnly = true) + public FbaAppointmentResponse detail(String customerId, String appointmentNo) { + InboundFbaAppointment appointment = find(customerId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + return response(appointment, items(appointment.getId())); + } + + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, + LocalDateTime appointmentAt, String status, String remark) { + List existingItems = items(existing.getId()); + Set existingLineIds = existingItems.stream() + .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); + if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getAppointmentStatus(), status) + || !Objects.equals(existing.getAppointmentRemark(), remark) + || existingLineIds.size() != requestedLineIds.size() + || !existingLineIds.equals(new HashSet<>(requestedLineIds))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Appointment number already exists with different appointment data"); + } + return response(existing, existingItems); + } + + private String commonFbaWarehouse(List lines) { + List warehouses = lines.stream().map(FbaAppointmentLineSnapshot::fbaWarehouseCode) + .map(InboundFbaAppointmentService::trimToNull).toList(); + if (warehouses.stream().anyMatch(Objects::isNull) || new HashSet<>(warehouses).size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Selected inbound lines must belong to the same non-empty FBA warehouse"); + } + return warehouses.get(0); + } + + private InboundFbaAppointment find(String customerId, String appointmentNo) { + return appointmentMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundFbaAppointment::getCustomerId, customerId) + .eq(InboundFbaAppointment::getAppointmentNo, appointmentNo)); + } + + private List items(Long appointmentId) { + return itemMapper.selectList(new LambdaQueryWrapper() + .eq(InboundFbaAppointmentItem::getAppointmentId, appointmentId) + .orderByAsc(InboundFbaAppointmentItem::getLineId)); + } + + private FbaAppointmentResponse response(InboundFbaAppointment appointment, + List items) { + return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(), + appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(), + appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), + appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), + items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), + item.getLineId(), item.getOrderId(), item.getInboundCode(), item.getFbaWarehouseCode())).toList()); + } + + private static String normalizeStatus(String value) { + String normalized = requiredTrimmed(value, "Appointment status is required").toUpperCase(Locale.ROOT); + try { + return FbaAppointmentStatus.valueOf(normalized).name(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Appointment status must be PENDING, SUCCESS, FAILED or CANCELLED"); + } + } + + private static String requiredTrimmed(String value, String message) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java new file mode 100644 index 000000000..58570f0d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -0,0 +1,243 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class InboundPlanService { + @Resource private InboundPlanMapper planMapper; + @Resource private InboundPlanLineMapper lineMapper; + @Resource private InboundPlanPalletMapper palletMapper; + @Resource private InboundPlanPalletAllocationMapper allocationMapper; + @Resource private InboundPlanOrderMapper orderMapper; + + @Transactional + public InboundPlanResponse convert(String customerId, Long selectedOrderId) { + String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId)); + if (containerNo == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Order does not exist in this tenant or container number is empty"); + } + + List orders = orderMapper.lockContainerOrders(customerId, containerNo); + if (orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found"); + } + InboundPlan existing = planMapper.findByContainer(customerId, containerNo); + if (existing != null) { + return detail(customerId, existing); + } + validateOrders(orders); + + LocalDateTime now = LocalDateTime.now(); + InboundPlan plan = new InboundPlan(); + plan.setId(IdWorker.getId()); + plan.setPlanNo("LIP" + plan.getId()); + plan.setCustomerId(customerId); + plan.setContainerNo(containerNo); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(orders.size()); + plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); + plan.setCreatedAt(now); + plan.setUpdatedAt(now); + + Map orderById = orders.stream() + .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); + Map lineByOrder = createLines(plan, orders); + PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), + order.packageCount())) + .toList()); + plan.setPalletCount(allocation.pallets().size()); + planMapper.insert(plan); + + lineByOrder.values().forEach(lineMapper::insert); + Map persistedPallets = createPallets(plan, allocation, orderById); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + + Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, + Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + for (SourceOrderSnapshot order : orders) { + int count = palletCounts.getOrDefault(order.orderId(), 0); + InboundPlanLine line = lineByOrder.get(order.orderId()); + line.setPlannedPalletCount(count); + lineMapper.updateById(line); + if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + throw new IllegalStateException("Failed to update order " + order.orderId()); + } + orderMapper.insertHistory(order.orderId(), order.currentStatus(), + "Container converted to inbound plan " + plan.getPlanNo(), now); + } + return detail(customerId, plan); + } + + @Transactional(readOnly = true) + public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); + if (plan == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + return detail(customerId, plan); + } + + private Map createLines(InboundPlan plan, List orders) { + Map result = new HashMap<>(); + for (SourceOrderSnapshot order : orders) { + InboundPlanLine line = new InboundPlanLine(); + line.setId(IdWorker.getId()); + line.setPlanId(plan.getId()); + line.setOrderId(order.orderId()); + line.setInboundCode(order.inboundCode()); + line.setFbaWarehouseCode(order.fbaWarehouseCode()); + line.setDestinationWarehouseId(order.destinationWarehouseId()); + line.setDestinationWarehouseCode(order.destinationWarehouseCode()); + line.setDestinationWarehouseName(order.destinationWarehouseName()); + line.setVolumeCbm(volume(order)); + line.setRequiredPackageCount(order.packageCount()); + line.setPlannedPalletCount(0); + result.put(order.orderId(), line); + } + return result; + } + + private Map createPallets( + InboundPlan plan, PalletAllocator.Result allocation, Map orderById) { + Map warehouseSamples = orderById.values().stream() + .collect(Collectors.toMap(this::warehouseKey, value -> value, (left, right) -> left)); + Map result = new HashMap<>(); + for (PalletAllocator.PalletDraft draft : allocation.pallets()) { + SourceOrderSnapshot sample = warehouseSamples.get(draft.warehouseKey()); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); + pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", result.size() + 1)); + pallet.setDestinationWarehouseId(sample.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(sample.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(sample.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + pallet.setSequenceNo(result.size() + 1); + pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); + pallet.setActualPackageCount(0); + palletMapper.insert(pallet); + result.put(draft, pallet); + } + return result; + } + + private void createAllocations(InboundPlan plan, PalletAllocator.Result allocation, + Map lineByOrder, + Map pallets) { + for (PalletAllocator.AllocationDraft draft : allocation.allocations()) { + InboundPlanPalletAllocation item = new InboundPlanPalletAllocation(); + item.setId(IdWorker.getId()); + item.setPlanId(plan.getId()); + item.setLineId(lineByOrder.get(draft.orderId()).getId()); + item.setPalletId(pallets.get(draft.pallet()).getId()); + item.setAllocatedVolumeCbm(draft.allocatedVolumeCbm()); + item.setPlannedPackageCount(draft.plannedPackageCount()); + item.setCountedPackageCount(0); + item.setPickedPackageCount(0); + item.setActualVolumeCbm(BigDecimal.ZERO); + item.setStatus("PENDING_COUNT"); + item.setVersion(0); + allocationMapper.insert(item); + } + } + + private void validateOrders(List orders) { + for (SourceOrderSnapshot order : orders) { + if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " is not ready for an inbound plan"); + } + if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + + " must have one destination warehouse consistent with all cargo details"); + } + if (order.inboundOrderNo() != null && !order.inboundOrderNo().isBlank()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Inbound code " + order.inboundCode() + " already belongs to an inbound plan"); + } + if (volume(order).signum() < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has negative volume"); + } + if (order.packageCount() < 0 || (order.packageCount() > 0 && volume(order).signum() == 0)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound code " + order.inboundCode() + " has invalid package count or volume"); + } + } + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan) { + if (!customerId.equals(plan.getCustomerId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + } + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId()).orderByAsc(InboundPlanPallet::getSequenceNo)); + List allocations = allocationMapper.selectList( + new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPalletAllocation::getId)); + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(), + plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), + lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), + line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), + line.getVolumeCbm(), line.getRequiredPackageCount(), line.getPlannedPalletCount())).toList(), + pallets.stream().map(pallet -> new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), + pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), + pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), + pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getActualPackageCount())).toList(), + allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), + item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), + item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList()); + } + + private BigDecimal volume(SourceOrderSnapshot order) { + return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); + } + + private String warehouseKey(SourceOrderSnapshot order) { + String id = trimToNull(order.destinationWarehouseId()); + String code = trimToNull(order.destinationWarehouseCode()); + return id != null ? "ID:" + id : code == null ? null : "CODE:" + code; + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java new file mode 100644 index 000000000..74f6f0af9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.inboundplan.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class PalletAllocator { + public static final BigDecimal PALLET_CAPACITY_CBM = new BigDecimal("2.0000"); + + private PalletAllocator() { + } + + public static Result allocate(List lines) { + Map> groups = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (line.volumeCbm() == null || line.volumeCbm().signum() < 0) { + throw new IllegalArgumentException("Volume must be zero or positive"); + } + if (line.packageCount() < 0) { + throw new IllegalArgumentException("Package count must be zero or positive"); + } + if (line.volumeCbm().signum() == 0 && line.packageCount() > 0) { + throw new IllegalArgumentException("Positive package count requires positive volume"); + } + groups.computeIfAbsent(line.warehouseKey(), ignored -> new ArrayList<>()).add(line); + } + + List pallets = new ArrayList<>(); + List allocations = new ArrayList<>(); + for (List group : groups.values()) { + PalletDraft current = null; + int sequence = 0; + for (LineInput line : group) { + BigDecimal remaining = line.volumeCbm(); + while (remaining.signum() > 0) { + if (current == null || current.remainingCapacity().signum() == 0) { + current = new PalletDraft(line.warehouseKey(), ++sequence, BigDecimal.ZERO); + pallets.add(current); + } + BigDecimal allocated = remaining.min(current.remainingCapacity()); + current.add(allocated); + allocations.add(new AllocationDraft(line.orderId(), current, allocated, 0)); + remaining = remaining.subtract(allocated); + } + } + } + return new Result(List.copyOf(pallets), distributePackages(lines, allocations)); + } + + private static List distributePackages(List lines, + List allocations) { + Map linesByOrder = new LinkedHashMap<>(); + for (LineInput line : lines) { + if (linesByOrder.put(line.orderId(), line) != null) { + throw new IllegalArgumentException("Duplicate order id " + line.orderId()); + } + } + Map> allocationsByOrder = new LinkedHashMap<>(); + for (AllocationDraft allocation : allocations) { + allocationsByOrder.computeIfAbsent(allocation.orderId(), ignored -> new ArrayList<>()).add(allocation); + } + + List result = new ArrayList<>(allocations.size()); + Map replacements = new LinkedHashMap<>(); + for (LineInput line : lines) { + List orderAllocations = allocationsByOrder.getOrDefault(line.orderId(), List.of()); + int assigned = 0; + for (int index = 0; index < orderAllocations.size(); index++) { + AllocationDraft allocation = orderAllocations.get(index); + int packages = index == orderAllocations.size() - 1 + ? line.packageCount() - assigned + : allocation.allocatedVolumeCbm().multiply(BigDecimal.valueOf(line.packageCount())) + .divide(line.volumeCbm(), 0, RoundingMode.DOWN).intValueExact(); + assigned += packages; + replacements.put(allocation, new AllocationDraft(allocation.orderId(), allocation.pallet(), + allocation.allocatedVolumeCbm(), packages)); + } + } + for (AllocationDraft allocation : allocations) { + result.add(replacements.get(allocation)); + } + return List.copyOf(result); + } + + public record LineInput(Long orderId, String warehouseKey, BigDecimal volumeCbm, int packageCount) { + } + + public static final class PalletDraft { + private final String warehouseKey; + private final int sequenceNo; + private BigDecimal allocatedVolumeCbm; + + private PalletDraft(String warehouseKey, int sequenceNo, BigDecimal allocatedVolumeCbm) { + this.warehouseKey = warehouseKey; + this.sequenceNo = sequenceNo; + this.allocatedVolumeCbm = allocatedVolumeCbm; + } + + private void add(BigDecimal volume) { + allocatedVolumeCbm = allocatedVolumeCbm.add(volume); + } + + public String warehouseKey() { + return warehouseKey; + } + + public int sequenceNo() { + return sequenceNo; + } + + public BigDecimal allocatedVolumeCbm() { + return allocatedVolumeCbm; + } + + public BigDecimal remainingCapacity() { + return PALLET_CAPACITY_CBM.subtract(allocatedVolumeCbm); + } + } + + public record AllocationDraft(Long orderId, PalletDraft pallet, BigDecimal allocatedVolumeCbm, + int plannedPackageCount) { + } + + public record Result(List pallets, List allocations) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java new file mode 100644 index 000000000..c3cd7e45f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/InventoryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.mapper") +public class InventoryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java new file mode 100644 index 000000000..953a3ceb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/InventoryCommandMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.command; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.command.mapper") +public class InventoryCommandMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java new file mode 100644 index 000000000..71d44c87b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.inventory.command.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/inventory") +@Tag(name = "Tail-leg inbound-order package inventory commands") +public class InventoryCommandController { + public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; + public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; + @Resource private InventoryCommandService service; + + @PostMapping("/hold") + @Operation(summary = "Move available inbound-order packages to hold") + public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.hold(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/release") + @Operation(summary = "Release held inbound-order packages") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/allocate") + @Operation(summary = "Allocate available inbound-order packages to an outbound order") + public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/unallocate") + @Operation(summary = "Cancel an outbound allocation") + public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/pick") + @Operation(summary = "Confirm picked inbound-order packages") + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/ship") + @Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty") + public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Move request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request)); + } + + @PostMapping("/adjust") + @Operation(summary = "Adjust inbound-order package inventory after a physical count") + public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody InventoryCommandRequests.Adjustment request) { + requirePermission(user, ADJUST_PERMISSION); + return ApiResponse.success(service.adjust(scope(user, request.customerId()), operator(user), request)); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform request must specify target tenant"); + } + return requestedCustomer.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java new file mode 100644 index 000000000..e768ccb21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.inventory.command.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +public final class InventoryCommandRequests { + private InventoryCommandRequests() { + } + + public record StockIn( + String customerId, + String customerName, + @NotBlank String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + @NotBlank String destinationWarehouseCode, + String destinationWarehouseName, + @NotNull Long inboundOrderId, + @NotBlank String inboundOrderNo, + @NotBlank String inboundCode, + @NotNull Long palletId, + @NotBlank String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + @Min(0) long packages, + String businessOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Move( + String customerId, + @NotNull Long positionId, + @Positive long packages, + String businessOrderNo, + String outboundOrderNo, + String sourceEventId, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } + + public record Adjustment( + String customerId, + @NotNull Long positionId, + long packageDelta, + @NotBlank String reason, + String businessOrderNo, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) { + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java new file mode 100644 index 000000000..944c38358 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.inventory.command.dto; + +public record InventoryCommandResponse( + Long operationId, + String operationType, + Long balanceId, + Long positionId, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + boolean palletEmpty, + boolean replayed +) { + public InventoryCommandResponse asReplay() { + return new InventoryCommandResponse(operationId, operationType, balanceId, positionId, + availablePackages, holdPackages, allocatedPackages, pickedPackages, palletEmpty, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java new file mode 100644 index 000000000..97b33ae24 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.command.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InventoryCommandMapper { + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND id=#{positionId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId}") + long palletPackages(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java new file mode 100644 index 000000000..e96f09a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -0,0 +1,453 @@ +package com.baiyue.lastleg.inventory.command.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; + +@Service +public class InventoryCommandService { + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryLedgerMapper ledgers; + @Resource private InventoryOperationMapper operations; + @Resource private InventoryCommandMapper commands; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InventoryCommandResponse stockIn(String customerId, String operatorId, + InventoryCommandRequests.StockIn request) { + return execute(customerId, operatorId, "STOCK_IN", request.idempotencyKey(), request, operation -> { + InventoryBalance balance = balance(customerId, request); + InventoryPosition position = position(customerId, balance, request); + long before = value(position.getAvailablePackages()); + position.setAvailablePackages(before + request.packages()); + position.setStatus("OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setAvailablePackages(value(balance.getAvailablePackages()) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, "STOCK_IN", + "OUTSIDE", "AVAILABLE", before, request.packages(), before + request.packages(), + request.businessOrderNo(), request.inboundOrderNo(), null, request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + @Transactional + public InventoryCommandResponse hold(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request); + } + + @Transactional + public InventoryCommandResponse release(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse allocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, request); + } + + @Transactional + public InventoryCommandResponse unallocate(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "UNALLOCATE", Bucket.ALLOCATED, Bucket.AVAILABLE, request); + } + + @Transactional + public InventoryCommandResponse pick(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, request); + } + + @Transactional + public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) { + return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); + requireAtLeast(value(balance.getPickedPackages()), request.packages(), "balance picked"); + long before = value(position.getPickedPackages()); + position.setPickedPackages(before - request.packages()); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + balance.setPickedPackages(value(balance.getPickedPackages()) - request.packages()); + touch(balance); + balances.updateById(balance); + boolean palletEmpty = commands.palletPackages(customerId, position.getPalletId()) == 0; + if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); + ledger(customerId, operatorId, operation.getId(), balance, position, "SHIP", + "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, palletEmpty); + }); + } + + @Transactional + public InventoryCommandResponse adjust(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request) { + if (request.packageDelta() == 0) { + throw badRequest("Adjustment package delta cannot be zero"); + } + return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + long before = value(position.getAvailablePackages()); + long after = before + request.packageDelta(); + long balanceAfter = value(balance.getAvailablePackages()) + request.packageDelta(); + if (after < 0 || balanceAfter < 0) { + throw conflict("Adjustment would make inventory negative"); + } + position.setAvailablePackages(after); + position.setStatus(positionPackages(position) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + balance.setAvailablePackages(balanceAfter); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, "ADJUST", + "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, + request.businessOrderNo(), null, null, null, request.idempotencyKey(), + request.reason() + remarkSuffix(request.remark())); + return response(operation, balance, position, false); + }); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request) { + return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { + Locked locked = locked(customerId, request.positionId()); + InventoryPosition position = locked.position(); + InventoryBalance balance = locked.balance(); + requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); + requireAtLeast(packages(balance, from), request.packages(), "balance " + from.name().toLowerCase()); + long beforeTarget = packages(position, to); + packages(position, from, packages(position, from) - request.packages()); + packages(position, to, beforeTarget + request.packages()); + position.setVersion(value(position.getVersion()) + 1); + position.setUpdatedAt(LocalDateTime.now()); + positions.updateById(position); + + packages(balance, from, packages(balance, from) - request.packages()); + packages(balance, to, packages(balance, to) + request.packages()); + touch(balance); + balances.updateById(balance); + ledger(customerId, operatorId, operation.getId(), balance, position, operationType, + from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), + request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), + request.idempotencyKey(), request.remark()); + return response(operation, balance, position, false); + }); + } + + private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) { + InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + if (balance != null) return balance; + balance = new InventoryBalance(); + balance.setId(IdWorker.getId()); + balance.setCustomerId(customerId); + balance.setCustomerName(trim(request.customerName())); + balance.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + balance.setOverseasWarehouseCode(trim(request.overseasWarehouseCode())); + balance.setOverseasWarehouseName(trim(request.overseasWarehouseName())); + balance.setDestinationWarehouseId(trim(request.destinationWarehouseId())); + balance.setDestinationWarehouseCode(request.destinationWarehouseCode().trim()); + balance.setDestinationWarehouseName(trim(request.destinationWarehouseName())); + balance.setInboundOrderId(request.inboundOrderId()); + balance.setInboundOrderNo(request.inboundOrderNo().trim()); + balance.setInboundCode(request.inboundCode().trim()); + zero(balance); + balance.setVersion(0L); + balance.setCreatedAt(LocalDateTime.now()); + balance.setUpdatedAt(balance.getCreatedAt()); + try { + balances.insert(balance); + return balance; + } catch (DuplicateKeyException exception) { + InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(), + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private InventoryPosition position(String customerId, InventoryBalance balance, + InventoryCommandRequests.StockIn request) { + String batchNo = trim(request.batchNo()) == null ? "DEFAULT" : request.batchNo().trim(); + InventoryPosition position = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (position != null) return position; + position = new InventoryPosition(); + position.setId(IdWorker.getId()); + position.setCustomerId(customerId); + position.setBalanceId(balance.getId()); + position.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + position.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + position.setPalletId(request.palletId()); + position.setPalletNo(request.palletNo().trim()); + position.setBatchNo(batchNo); + position.setWarehouseLocationId(request.warehouseLocationId()); + String locationCode = trim(request.warehouseLocationCode()); + position.setWarehouseLocationCode(locationCode == null + ? "LOC-" + defaultCode(balance) + "-DEFAULT" : locationCode); + position.setAvailablePackages(0L); + position.setHoldPackages(0L); + position.setAllocatedPackages(0L); + position.setPickedPackages(0L); + position.setReceivingPackages(0L); + position.setStatus("OCCUPIED"); + position.setVersion(0L); + position.setCreatedAt(LocalDateTime.now()); + position.setUpdatedAt(position.getCreatedAt()); + try { + positions.insert(position); + return position; + } catch (DuplicateKeyException exception) { + InventoryPosition concurrent = positions.lockPosition(customerId, balance.getId(), request.palletId(), batchNo); + if (concurrent != null) return concurrent; + throw exception; + } + } + + private Locked locked(String customerId, Long positionId) { + InventoryPosition position = commands.lockPosition(customerId, positionId); + if (position == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory position not found"); + InventoryBalance balance = commands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw new IllegalStateException("Inventory position balance is missing"); + return new Locked(balance, position); + } + + private InventoryCommandResponse execute(String customerId, String operatorId, String operationType, + String key, Object request, Work work) { + String normalizedKey = required(key, "Idempotency key is required"); + String requestHash = hash(request); + InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); + if (existing != null) return replay(existing, operationType, requestHash); + + InventoryOperation operation = new InventoryOperation(); + operation.setId(IdWorker.getId()); + operation.setCustomerId(customerId); + operation.setIdempotencyKey(normalizedKey); + operation.setOperationType(operationType); + operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); + try { + operations.insert(operation); + } catch (DuplicateKeyException exception) { + InventoryOperation concurrent = operations.findIdempotency(customerId, normalizedKey); + if (concurrent != null) return replay(concurrent, operationType, requestHash); + throw exception; + } + + InventoryCommandResponse response = work.run(operation); + operation.setStatus("COMPLETED"); + operation.setResultJson(write(response)); + operation.setUpdatedAt(LocalDateTime.now()); + operations.updateById(operation); + return response; + } + + private InventoryCommandResponse replay(InventoryOperation operation, String type, String requestHash) { + if (!type.equals(operation.getOperationType()) || !requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different inventory request"); + } + if (!"COMPLETED".equals(operation.getStatus()) || trim(operation.getResultJson()) == null) { + throw conflict("Inventory operation is still processing"); + } + try { + return objectMapper.readValue(operation.getResultJson(), InventoryCommandResponse.class).asReplay(); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read inventory idempotency result", exception); + } + } + + private void ledger(String customerId, String operatorId, Long operationId, InventoryBalance balance, + InventoryPosition position, String businessType, String from, String to, + long beforePackages, long deltaPackages, long afterPackages, + String businessOrderNo, String inboundOrderNo, String outboundOrderNo, + String sourceEventId, String idempotencyKey, String remark) { + InventoryLedger ledger = new InventoryLedger(); + ledger.setId(IdWorker.getId()); + ledger.setCustomerId(customerId); + ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); + ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); + ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); + ledger.setBatchNo(position.getBatchNo()); + ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(businessType); + ledger.setBusinessOrderId(balance.getInboundOrderId()); + ledger.setBusinessOrderNo(trim(businessOrderNo)); + ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(trim(inboundOrderNo) == null + ? balance.getInboundOrderNo() : trim(inboundOrderNo)); + ledger.setOutboundOrderNo(trim(outboundOrderNo)); + ledger.setSourceEventId(trim(sourceEventId)); + ledger.setIdempotencyKey(idempotencyKey + ":" + operationId); + ledger.setStatusFrom(from); + ledger.setStatusTo(to); + ledger.setBeforePackages(beforePackages); + ledger.setDeltaPackages(deltaPackages); + ledger.setAfterPackages(afterPackages); + ledger.setBeforeQuantity(0L); + ledger.setDeltaQuantity(0L); + ledger.setAfterQuantity(0L); + ledger.setOperatorId(trim(operatorId)); + ledger.setRemark(trim(remark)); + ledger.setOccurredAt(LocalDateTime.now()); + ledger.setCreatedAt(ledger.getOccurredAt()); + ledgers.insert(ledger); + } + + private InventoryCommandResponse response(InventoryOperation operation, InventoryBalance balance, + InventoryPosition position, boolean palletEmpty) { + return new InventoryCommandResponse(operation.getId(), operation.getOperationType(), balance.getId(), + position.getId(), value(balance.getAvailablePackages()), value(balance.getHoldPackages()), + value(balance.getAllocatedPackages()), value(balance.getPickedPackages()), palletEmpty, false); + } + + private static void touch(InventoryBalance balance) { + balance.setVersion(value(balance.getVersion()) + 1); + balance.setUpdatedAt(LocalDateTime.now()); + } + + private static void zero(InventoryBalance balance) { + balance.setAvailablePackages(0L); balance.setHoldPackages(0L); balance.setAllocatedPackages(0L); + balance.setPickedPackages(0L); balance.setInTransitPackages(0L); balance.setReceivingPackages(0L); + balance.setProcurementPackages(0L); + } + + private static long positionPackages(InventoryPosition position) { + return value(position.getAvailablePackages()) + value(position.getHoldPackages()) + + value(position.getAllocatedPackages()) + value(position.getPickedPackages()) + + value(position.getReceivingPackages()); + } + + private static long packages(InventoryPosition position, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(position.getAvailablePackages()); + case HOLD -> value(position.getHoldPackages()); + case ALLOCATED -> value(position.getAllocatedPackages()); + case PICKED -> value(position.getPickedPackages()); + }; + } + + private static void packages(InventoryPosition position, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> position.setAvailablePackages(value); + case HOLD -> position.setHoldPackages(value); + case ALLOCATED -> position.setAllocatedPackages(value); + case PICKED -> position.setPickedPackages(value); + } + } + + private static long packages(InventoryBalance balance, Bucket bucket) { + return switch (bucket) { + case AVAILABLE -> value(balance.getAvailablePackages()); + case HOLD -> value(balance.getHoldPackages()); + case ALLOCATED -> value(balance.getAllocatedPackages()); + case PICKED -> value(balance.getPickedPackages()); + }; + } + + private static void packages(InventoryBalance balance, Bucket bucket, long value) { + switch (bucket) { + case AVAILABLE -> balance.setAvailablePackages(value); + case HOLD -> balance.setHoldPackages(value); + case ALLOCATED -> balance.setAllocatedPackages(value); + case PICKED -> balance.setPickedPackages(value); + } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize inventory operation", exception); + } + } + + private static String defaultCode(InventoryBalance balance) { + String code = trim(balance.getOverseasWarehouseCode()); + return code == null ? balance.getOverseasWarehouseId() : code; + } + + private static void requireAtLeast(long actual, long requested, String field) { + if (actual < requested) throw conflict("Insufficient " + field + " packages"); + } + + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw badRequest(message); + return normalized; + } + + private static String trim(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static String remarkSuffix(String remark) { + String value = trim(remark); + return value == null ? "" : "; " + value; + } + + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private enum Bucket { AVAILABLE, HOLD, ALLOCATED, PICKED } + private record Locked(InventoryBalance balance, InventoryPosition position) {} + @FunctionalInterface private interface Work { InventoryCommandResponse run(InventoryOperation operation); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java new file mode 100644 index 000000000..c7e62a204 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.inventory.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; + +/** Real-time package balance for one inbound order at one overseas warehouse. */ +@Data +@TableName("ll_inventory_balance") +public class InventoryBalance { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String customerName; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long inboundOrderId; + private String inboundOrderNo; + private String inboundCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java new file mode 100644 index 000000000..0e9dc4d95 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inventory.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; + +/** Append-only inventory fact. Never update or delete this entity. */ +@Data +@TableName("ll_inventory_ledger") +public class InventoryLedger { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private Long positionId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long inboundOrderId; + private Long palletId; + private String palletNo; + private String batchNo; + private String skuCode; + private String cartonMark; + private Long cartonPackages; + private String warehouseLocationCode; + private String businessType; + private Long businessOrderId; + private String businessOrderNo; + private String inboundCode; + private String inboundOrderNo; + private String outboundOrderNo; + private String sourceEventId; + private String idempotencyKey; + private String statusFrom; + private String statusTo; + private Long beforePackages; + private Long deltaPackages; + private Long afterPackages; + /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + private Long beforeQuantity; + private Long deltaQuantity; + private Long afterQuantity; + private String operatorId; + private String remark; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java new file mode 100644 index 000000000..43317683d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inventory.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; + +/** Command-level idempotency record for inventory mutations. */ +@Data +@TableName("ll_inventory_operation") +public class InventoryOperation { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java new file mode 100644 index 000000000..3e5b276ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inventory.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; + +/** Physical stock snapshot. A location is currently informational only. */ +@Data +@TableName("ll_inventory_position") +public class InventoryPosition { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private String customerId; + private Long balanceId; + private String overseasWarehouseId; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private String status; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java new file mode 100644 index 000000000..437a9dcab --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.model.InventorySummary; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1") + InventoryBalance findDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{overseasWarehouseId} " + + "AND destination_warehouse_code=#{destinationWarehouseCode} " + + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE") + InventoryBalance lockDimension(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("destinationWarehouseCode") String destinationWarehouseCode, + @Param("inboundCode") String inboundCode, + @Param("offset") long offset, + @Param("limit") int limit); + + @Select("") + InventorySummary summarize(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java new file mode 100644 index 000000000..f95b777ba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +public interface InventoryLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryLedger findIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("") + List listTenant(@Param("customerId") String customerId, + @Param("overseasWarehouseId") String overseasWarehouseId, + @Param("inboundCode") String inboundCode, + @Param("businessType") String businessType, + @Param("businessOrderNo") String businessOrderNo, + @Param("startedAt") LocalDateTime startedAt, + @Param("endedAt") LocalDateTime endedAt, + @Param("offset") long offset, + @Param("limit") int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java new file mode 100644 index 000000000..3b6a16229 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface InventoryOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") + InventoryOperation findIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); + + @Select("SELECT * FROM ll_inventory_operation WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1 FOR UPDATE") + InventoryOperation lockIdempotency(@Param("customerId") String customerId, + @Param("idempotencyKey") String idempotencyKey); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java new file mode 100644 index 000000000..59b438680 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryPositionMapper extends BaseMapper { + @Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " + + "WHERE b.customer_id=#{customerId} AND p.balance_id=#{balanceId} ORDER BY p.pallet_no,p.batch_no") + List listBalance(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "ORDER BY balance_id,batch_no") + List listPallet(@Param("customerId") String customerId, + @Param("palletId") Long palletId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("customerId") String customerId, + @Param("balanceId") Long balanceId, + @Param("palletId") Long palletId, + @Param("batchNo") String batchNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java new file mode 100644 index 000000000..39b7585cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.model; + +import lombok.Data; + +@Data +public class InventorySummary { + private Long balanceCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java new file mode 100644 index 000000000..049e9f827 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/InventoryQueryMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.query; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.inventory.query.mapper") +public class InventoryQueryMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java new file mode 100644 index 000000000..f9b9f759d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.inventory.query.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.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@Tag(name = "Inventory queries") +public class InventoryQueryController { + private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; + + @Resource private InventoryQueryService service; + + @GetMapping(BASE_PATH + "/summary") + @Operation(summary = "Get inbound-order package inventory totals") + public ApiResponse summary( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.dashboard(scope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances") + @Operation(summary = "Page inventory by customer, warehouse and inbound order") + public ApiResponse> balances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(service.balances(scope(user, customerId), query)); + } + + @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @Operation(summary = "List pallet and batch positions for one inbound-order balance") + public ApiResponse> positions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long balanceId, + @Valid InventoryQueryRequests.PositionQuery query) { + return ApiResponse.success(service.positions(scope(user, customerId), balanceId, query)); + } + + @GetMapping(BASE_PATH + "/ledger") + @Operation(summary = "Page append-only inventory movements") + public ApiResponse> ledgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return ApiResponse.success(service.ledgers(scope(user, customerId), query)); + } + + @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV") + public ResponseEntity exportBalances( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return csv(service.exportBalances(scope(user, customerId), query), "inventory-balances.csv"); + } + + @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @Operation(summary = "Export filtered inventory movements as UTF-8 CSV") + public ResponseEntity exportLedgers( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InventoryQueryRequests.LedgerQuery query) { + return csv(service.exportLedgers(scope(user, customerId), query), "inventory-ledger.csv"); + } + + private static ResponseEntity csv(byte[] body, String filename) { + return ResponseEntity.ok() + .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) + .body(body); + } + + private String scope(AuthenticatedUser user, String requestedCustomer) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Platform queries must specify a customer"); + } + return requestedCustomer; + } + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java new file mode 100644 index 000000000..64fd75cb9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryBalanceView( + Long id, + String customerId, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java new file mode 100644 index 000000000..d73cd23af --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.dto; + +public record InventoryDashboardResponse( + long inboundOrderCount, + long palletCount, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long inTransitPackages, + long receivingPackages, + long procurementPackages +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java new file mode 100644 index 000000000..00b602cbc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryLedgerView( + Long id, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + String palletNo, + String batchNo, + String warehouseLocationCode, + String businessType, + String businessOrderNo, + String outboundOrderNo, + String statusFrom, + String statusTo, + long beforePackages, + long deltaPackages, + long afterPackages, + String operatorId, + String remark, + LocalDateTime occurredAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java new file mode 100644 index 000000000..8f67d79d5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import java.time.LocalDateTime; + +public record InventoryPositionView( + Long id, + Long balanceId, + String customerId, + String overseasWarehouseId, + String destinationWarehouseCode, + Long inboundOrderId, + String inboundOrderNo, + String inboundCode, + Long palletId, + String palletNo, + String batchNo, + Long warehouseLocationId, + String warehouseLocationCode, + long totalPackages, + long availablePackages, + long holdPackages, + long allocatedPackages, + long pickedPackages, + long receivingPackages, + String status, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java new file mode 100644 index 000000000..0783e3aef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.inventory.query.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public final class InventoryQueryRequests { + private InventoryQueryRequests() { + } + + public record BalanceQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String customerName, + String overseasWarehouseId, + String overseasWarehouseCode, + String destinationWarehouseCode, + String inboundOrderNo, + String inboundCode, + String businessOrderNo, + String inventoryStatus + ) { + public BalanceQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + + public record PositionQuery(String palletNo, String batchNo, String inventoryStatus) { + } + + public record LedgerQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String overseasWarehouseId, + String destinationWarehouseCode, + String businessType, + String businessOrderNo, + String inboundOrderNo, + String inboundCode, + String outboundOrderNo, + String palletNo, + String batchNo, + LocalDateTime occurredFrom, + LocalDateTime occurredTo + ) { + public LedgerQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java new file mode 100644 index 000000000..ab7344b37 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface InventoryQueryMapper { + String BALANCE_FILTER = " WHERE customer_id=#{customerId}" + + " AND customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + "" + + " AND available_packages > 0" + + " AND hold_packages > 0" + + " AND allocated_packages > 0" + + " AND picked_packages > 0" + + " AND in_transit_packages > 0" + + " AND receiving_packages > 0" + + " AND procurement_packages > 0" + + ""; + + String JOINED_BALANCE_FILTER = " WHERE b.customer_id=#{customerId}" + + " AND b.customer_name LIKE CONCAT('%',#{q.customerName},'%')" + + " AND b.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND b.overseas_warehouse_code=#{q.overseasWarehouseCode}" + + " AND b.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + "" + + " AND b.available_packages > 0" + + " AND b.hold_packages > 0" + + " AND b.allocated_packages > 0" + + " AND b.picked_packages > 0" + + " AND b.in_transit_packages > 0" + + " AND b.receiving_packages > 0" + + " AND b.procurement_packages > 0" + + ""; + + @Select("") + List balances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countBalances(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + InventoryAggregateProjection aggregate(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + long countPallets(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.BalanceQuery query); + + @Select("") + List positions(@Param("customerId") String customerId, + @Param("balanceId") long balanceId, + @Param("q") InventoryQueryRequests.PositionQuery query); + + @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} LIMIT 1") + InventoryBalance ownedBalance(@Param("customerId") String customerId, @Param("balanceId") long balanceId); + + String LEDGER_FILTER = " WHERE customer_id=#{customerId}" + + " AND overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND business_type=#{q.businessType}" + + " AND business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%')" + + " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND outbound_order_no LIKE CONCAT('%',#{q.outboundOrderNo},'%')" + + " AND pallet_no LIKE CONCAT('%',#{q.palletNo},'%')" + + " AND batch_no LIKE CONCAT('%',#{q.batchNo},'%')" + + " AND occurred_at >=#{q.occurredFrom}" + + " AND occurred_at <=#{q.occurredTo}"; + + @Select("") + List ledgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countLedgers(@Param("customerId") String customerId, + @Param("q") InventoryQueryRequests.LedgerQuery query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java new file mode 100644 index 000000000..066d81207 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.model; + +import lombok.Data; + +@Data +public class InventoryAggregateProjection { + private Long dimensionCount; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long inTransitPackages; + private Long receivingPackages; + private Long procurementPackages; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java new file mode 100644 index 000000000..7b1b68020 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java @@ -0,0 +1,182 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryLedgerView; +import com.baiyue.lastleg.inventory.query.dto.InventoryPositionView; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +public class InventoryQueryService { + private static final int EXPORT_LIMIT = 10_000; + private static final Set BALANCE_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "IN_TRANSIT", "RECEIVING", "PROCUREMENT"); + private static final Set POSITION_STATUSES = Set.of( + "AVAILABLE", "HOLD", "ALLOCATED", "PICKED", "RECEIVING"); + + @Resource private InventoryQueryMapper mapper; + + public InventoryDashboardResponse dashboard(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + InventoryAggregateProjection value = mapper.aggregate(customerId, query); + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + return new InventoryDashboardResponse(number(value.getDimensionCount()), mapper.countPallets(customerId, query), + availablePackages + holdPackages + allocatedPackages + pickedPackages + inTransitPackages + + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages); + } + + public PageResult balances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + long total = mapper.countBalances(customerId, query); + List records = total == 0 ? List.of() : mapper.balances( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::balanceView).toList(); + return page(records, query.page(), query.size(), total); + } + + public List positions(String customerId, long balanceId, + InventoryQueryRequests.PositionQuery query) { + validateStatus(query.inventoryStatus(), POSITION_STATUSES); + InventoryBalance balance = mapper.ownedBalance(customerId, balanceId); + if (balance == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found"); + } + return mapper.positions(customerId, balanceId, query).stream().map(value -> positionView(value, balance)).toList(); + } + + public PageResult ledgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + long total = mapper.countLedgers(customerId, query); + List records = total == 0 ? List.of() : mapper.ledgers( + customerId, query, offset(query.page(), query.size()), query.size()).stream().map(this::ledgerView).toList(); + return page(records, query.page(), query.size(), total); + } + + public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) { + validateStatus(query.inventoryStatus(), BALANCE_STATUSES); + List rows = new ArrayList<>(); + rows.add("customerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode," + + "destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,totalPackages,availablePackages," + + "holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages"); + mapper.balances(customerId, query, 0, EXPORT_LIMIT).stream().map(this::balanceView).forEach(v -> rows.add( + String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), + csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), + csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), + csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), + csv(v.procurementPackages())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) { + validateDateRange(query); + List rows = new ArrayList<>(); + rows.add("occurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo," + + "inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo," + + "beforePackages,deltaPackages,afterPackages,operatorId,remark"); + mapper.ledgers(customerId, query, 0, EXPORT_LIMIT).stream().map(this::ledgerView).forEach(v -> rows.add( + String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), + csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), + csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), + csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), + csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())))); + return ("\uFEFF" + String.join("\r\n", rows)).getBytes(StandardCharsets.UTF_8); + } + + private InventoryBalanceView balanceView(InventoryBalance value) { + long availablePackages = number(value.getAvailablePackages()); + long holdPackages = number(value.getHoldPackages()); + long allocatedPackages = number(value.getAllocatedPackages()); + long pickedPackages = number(value.getPickedPackages()); + long inTransitPackages = number(value.getInTransitPackages()); + long receivingPackages = number(value.getReceivingPackages()); + long procurementPackages = number(value.getProcurementPackages()); + return new InventoryBalanceView(value.getId(), value.getCustomerId(), value.getCustomerName(), value.getOverseasWarehouseId(), + value.getOverseasWarehouseCode(), value.getOverseasWarehouseName(), value.getDestinationWarehouseId(), + value.getDestinationWarehouseCode(), value.getDestinationWarehouseName(), value.getInboundOrderId(), + value.getInboundOrderNo(), value.getInboundCode(), availablePackages + holdPackages + allocatedPackages + pickedPackages + + inTransitPackages + receivingPackages + procurementPackages, + availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, + receivingPackages, procurementPackages, value.getUpdatedAt()); + } + + private InventoryPositionView positionView(InventoryPosition value, InventoryBalance balance) { + long available = number(value.getAvailablePackages()); + long hold = number(value.getHoldPackages()); + long allocated = number(value.getAllocatedPackages()); + long picked = number(value.getPickedPackages()); + long receiving = number(value.getReceivingPackages()); + return new InventoryPositionView(value.getId(), value.getBalanceId(), value.getCustomerId(), + value.getOverseasWarehouseId(), value.getDestinationWarehouseCode(), balance.getInboundOrderId(), + balance.getInboundOrderNo(), balance.getInboundCode(), + value.getPalletId(), value.getPalletNo(), value.getBatchNo(), value.getWarehouseLocationId(), + value.getWarehouseLocationCode(), available + hold + allocated + picked + receiving, + available, hold, allocated, picked, receiving, value.getStatus(), + value.getUpdatedAt()); + } + + private InventoryLedgerView ledgerView(InventoryLedger value) { + return new InventoryLedgerView(value.getId(), value.getCustomerId(), value.getOverseasWarehouseId(), + value.getDestinationWarehouseCode(), value.getInboundOrderId(), value.getInboundOrderNo(), value.getInboundCode(), + value.getPalletNo(), value.getBatchNo(), + value.getWarehouseLocationCode(), value.getBusinessType(), value.getBusinessOrderNo(), + value.getOutboundOrderNo(), value.getStatusFrom(), value.getStatusTo(), + number(value.getBeforePackages()), number(value.getDeltaPackages()), number(value.getAfterPackages()), + value.getOperatorId(), value.getRemark(), value.getOccurredAt()); + } + + private static void validateStatus(String status, Set accepted) { + if (status != null && !status.isBlank() && !accepted.contains(status)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported inventory status: " + status); + } + } + + private static void validateDateRange(InventoryQueryRequests.LedgerQuery query) { + if (query.occurredFrom() != null && query.occurredTo() != null + && query.occurredFrom().isAfter(query.occurredTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "occurredFrom must not be after occurredTo"); + } + } + + private static long offset(long page, long size) { + return Math.multiplyExact(page - 1, size); + } + + private static long number(Long value) { + return value == null ? 0 : value; + } + + private static PageResult page(List records, long page, long size, long total) { + long pages = total == 0 ? 0 : (total + size - 1) / size; + return new PageResult<>(records, page, size, total, pages); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java new file mode 100644 index 000000000..ec4dc3988 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventInbox; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegEventInboxMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java new file mode 100644 index 000000000..1b5a957a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderDetail; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderDetailMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java new file mode 100644 index 000000000..f45023ef4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface LastlegOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") + LastlegOrder findSource(@Param("customerId") String customerId, + @Param("sourceRegion") String sourceRegion, + @Param("sourceInboundId") String sourceInboundId); + + @Select("SELECT COUNT(*) FROM ll_order WHERE customer_id=#{customerId} AND container_no=#{containerNo}") + long countContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); + + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); + + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + + "WHERE customer_id=#{customerId} AND id=#{id} " + + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") long packages); + + @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + + "picked_package_count=picked_package_count-#{packages}," + + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + + "WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " + + "AND shipped_package_count+#{packages} <= package_count") + int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, + @Param("packages") long packages, @Param("outboundNo") String outboundNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java new file mode 100644 index 000000000..4a52568cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java @@ -0,0 +1,3 @@ +package com.baiyue.lastleg.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import org.apache.ibatis.annotations.Mapper; +@Mapper public interface LastlegOrderStatusHistoryMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java new file mode 100644 index 000000000..5a24a2e5e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java @@ -0,0 +1,5 @@ +/** + * MyBatis-Plus single-table data access for the last-leg database. + * / 尾程数据库的 MyBatis-Plus 单表访问层。 + */ +package com.baiyue.lastleg.mapper; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java new file mode 100644 index 000000000..ed349662b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/ProcurementMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.procurement; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.procurement.mapper") +public class ProcurementMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java new file mode 100644 index 000000000..d8bb670f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements") +public class MerchantOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @PostMapping + public ApiResponse create( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody OverseasProcurementRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java new file mode 100644 index 000000000..2d0e71a37 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -0,0 +1,96 @@ +package com.baiyue.lastleg.procurement.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.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/overseas-procurements") +public class PlatformOverseasProcurementController { + @Resource private OverseasProcurementService service; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid OverseasProcurementRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(trim(customerId), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PostMapping("/{orderNo}/review") + public ApiResponse review( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { + requirePermission(user, OverseasProcurementService.REVIEW_PERMISSION); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PatchMapping("/{orderNo}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, request)); + } + + @PostMapping("/{orderNo}/outbounds") + public ApiResponse addOutbound( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) { + requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + String value = trim(customerId); + if (value == null || "0".equals(value)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return value; + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} 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 new file mode 100644 index 000000000..a361ed7bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.procurement.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public final class OverseasProcurementRequests { + private OverseasProcurementRequests() {} + + public record Create( + @Size(max = 255) String customerName, + @NotBlank @Size(max = 128) String purchaseCategory, + @NotBlank @Size(max = 255) String productName, + @Size(max = 500) String productSpecification, + @Size(max = 2000) String productUrl, + @NotNull @Positive Long purchaseQuantity, + @DecimalMin(value = "0.00") BigDecimal budgetUnitPrice, + @Size(min = 3, max = 3) String currency, + @Size(max = 1000) String receivingAddress, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) {} + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String customerKeyword, String purchaseCategory, + String productName, String status, String purchaseWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo) {} + + public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 128) String purchaseCategory, + @Size(max = 255) String productName, + @Size(max = 500) String productSpecification, + @Size(max = 2000) String productUrl, + @Min(0) Long inboundQuantity, + @Size(max = 128) String purchaseWarehouseId, + @Size(max = 255) String purchaseWarehouseName, + @Size(max = 128) String domesticCarrier, + @Size(max = 128) String domesticTrackingNo, + @Size(max = 128) String overseasCarrier, + @Size(max = 128) String overseasTrackingNo, + LocalDateTime estimatedArrivalAt, + @Size(max = 1000) String remark) {} + + public record AddOutbound( + @NotNull @Positive Long quantity, + @NotBlank @Size(max = 128) String outboundNo, + @Size(max = 128) String carrier, + @Size(max = 128) String trackingNo, + LocalDateTime outboundAt, + @Size(max = 1000) String remark) {} +} 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 new file mode 100644 index 000000000..a3d3dd5f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.procurement.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class OverseasProcurementResponses { + private OverseasProcurementResponses() {} + + public record Summary( + Long id, String orderNo, String customerId, String customerName, + String purchaseCategory, String productName, String productSpecification, + long purchaseQuantity, BigDecimal budgetUnitPrice, String currency, + String purchaseWarehouseId, String purchaseWarehouseName, + long inboundQuantity, long outboundQuantity, long inventoryQuantity, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record Outbound( + Long id, long quantity, String outboundNo, String carrier, String trackingNo, + LocalDateTime outboundAt, String remark, String createdBy, LocalDateTime createdAt) {} + + public record History( + String fromStatus, String toStatus, String operatorId, String remark, + LocalDateTime createdAt) {} + + public record Detail( + Summary summary, String productUrl, String receivingAddress, + String contactName, String contactPhone, + String domesticCarrier, String domesticTrackingNo, + String overseasCarrier, String overseasTrackingNo, + LocalDateTime estimatedArrivalAt, String remark, + String createdBy, String reviewedBy, String reviewRemark, LocalDateTime reviewedAt, + List outbounds, List histories) {} +} 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 new file mode 100644 index 000000000..49c3b189e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.procurement.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_overseas_procurement_order") +public class OverseasProcurementOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String customerName; + private String orderNo; + private String purchaseCategory; + private String productName; + private String productSpecification; + private String productUrl; + private Long purchaseQuantity; + private BigDecimal budgetUnitPrice; + private String currency; + private String receivingAddress; + private String contactName; + private String contactPhone; + private String purchaseWarehouseId; + private String purchaseWarehouseName; + private Long inboundQuantity; + private Long outboundQuantity; + private String domesticCarrier; + private String domesticTrackingNo; + private String overseasCarrier; + private String overseasTrackingNo; + private LocalDateTime estimatedArrivalAt; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private String reviewedBy; + private String reviewRemark; + private LocalDateTime reviewedAt; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java new file mode 100644 index 000000000..696c6d40f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOutbound.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.procurement.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_overseas_procurement_outbound") +public class OverseasProcurementOutbound { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private Long quantity; + private String outboundNo; + private String carrier; + private String trackingNo; + private LocalDateTime outboundAt; + private String remark; + private String createdBy; + 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 new file mode 100644 index 000000000..68776e3d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.procurement.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_overseas_procurement_status_history") +public class OverseasProcurementStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java new file mode 100644 index 000000000..e22af6a33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface OverseasProcurementOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + OverseasProcurementOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + OverseasProcurementOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_overseas_procurement_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + OverseasProcurementOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Update("UPDATE ll_overseas_procurement_order SET outbound_quantity=outbound_quantity+#{quantity},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND customer_id=#{customerId} " + + "AND inbound_quantity-outbound_quantity>=#{quantity}") + int addOutbound(@Param("id") Long id, @Param("customerId") String customerId, @Param("quantity") long quantity); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java new file mode 100644 index 000000000..3f96d9478 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface OverseasProcurementOutboundMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java new file mode 100644 index 000000000..b35f731e1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.procurement.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface OverseasProcurementStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_overseas_procurement_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} 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 new file mode 100644 index 000000000..d955c88d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -0,0 +1,293 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.Set; + +@Service +public class OverseasProcurementService { + public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; + public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); + + @Resource private OverseasProcurementOrderMapper orders; + @Resource private OverseasProcurementOutboundMapper outbounds; + @Resource private OverseasProcurementStatusHistoryMapper histories; + @Resource private ObjectMapper objectMapper; + + @Transactional + public OverseasProcurementResponses.Detail create(String customerId, String operatorId, + OverseasProcurementRequests.Create request) { + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + OverseasProcurementOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, requestHash); + + LocalDateTime now = LocalDateTime.now(); + OverseasProcurementOrder order = new OverseasProcurementOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setCustomerName(trim(request.customerName())); + order.setOrderNo("HC" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setPurchaseCategory(request.purchaseCategory().trim()); + order.setProductName(request.productName().trim()); + order.setProductSpecification(trim(request.productSpecification())); + order.setProductUrl(trim(request.productUrl())); + order.setPurchaseQuantity(request.purchaseQuantity()); + order.setBudgetUnitPrice(request.budgetUnitPrice()); + order.setCurrency(defaultCurrency(request.currency())); + order.setReceivingAddress(trim(request.receivingAddress())); + order.setContactName(request.contactName().trim()); + order.setContactPhone(request.contactPhone().trim()); + order.setInboundQuantity(0L); + order.setOutboundQuantity(0L); + order.setStatus(PENDING_REVIEW); + order.setRemark(trim(request.remark())); + order.setIdempotencyKey(key); + order.setRequestHash(requestHash); + order.setCreatedBy(operatorId); + order.setVersion(0L); + order.setCreatedAt(now); + order.setUpdatedAt(now); + try { + orders.insert(order); + } catch (DuplicateKeyException exception) { + OverseasProcurementOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, requestHash); + throw exception; + } + history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page( + String customerId, OverseasProcurementRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), OverseasProcurementOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), OverseasProcurementOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.customerKeyword()), value -> value + .like(OverseasProcurementOrder::getCustomerId, query.customerKeyword()) + .or().like(OverseasProcurementOrder::getCustomerName, query.customerKeyword())) + .like(StringUtils.hasText(query.purchaseCategory()), OverseasProcurementOrder::getPurchaseCategory, + query.purchaseCategory()) + .like(StringUtils.hasText(query.productName()), OverseasProcurementOrder::getProductName, + query.productName()) + .eq(StringUtils.hasText(query.status()), OverseasProcurementOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .eq(StringUtils.hasText(query.purchaseWarehouseId()), OverseasProcurementOrder::getPurchaseWarehouseId, + query.purchaseWarehouseId()) + .ge(query.createdFrom() != null, OverseasProcurementOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OverseasProcurementOrder::getCreatedAt, query.createdTo()) + .orderByDesc(OverseasProcurementOrder::getCreatedAt) + .orderByDesc(OverseasProcurementOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), query.page(), query.size(), + result.getTotal(), result.getPages()); + } + + public OverseasProcurementResponses.Detail detail(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return new OverseasProcurementResponses.Detail(summary(order), order.getProductUrl(), + order.getReceivingAddress(), order.getContactName(), order.getContactPhone(), + order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), + order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), + order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), + outbounds.listOrder(customerId, order.getId()).stream().map(this::outbound).toList(), + histories.listOrder(customerId, order.getId()).stream().map(value -> + new OverseasProcurementResponses.History(value.getFromStatus(), value.getToStatus(), + value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public OverseasProcurementResponses.Detail review(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.Review request) { + String next = switch (request.decision().trim().toUpperCase()) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + OverseasProcurementOrder order = lock(customerId, orderNo); + if (next.equals(order.getStatus())) return detail(customerId, orderNo); + 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.setReviewedAt(now); + touch(order, now); + orders.updateById(order); + history(order, PENDING_REVIEW, next, operatorId, request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public OverseasProcurementResponses.Detail update(String customerId, String orderNo, + OverseasProcurementRequests.Update request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + if (request.inboundQuantity() != null) { + if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); + if (request.inboundQuantity() < value(order.getOutboundQuantity())) { + throw conflict("Inbound quantity cannot be lower than the recorded outbound quantity"); + } + order.setInboundQuantity(request.inboundQuantity()); + } + setIfText(request.purchaseCategory(), order::setPurchaseCategory); + setIfText(request.productName(), order::setProductName); + setIfPresent(request.productSpecification(), order::setProductSpecification); + setIfPresent(request.productUrl(), order::setProductUrl); + setIfPresent(request.purchaseWarehouseId(), order::setPurchaseWarehouseId); + setIfPresent(request.purchaseWarehouseName(), order::setPurchaseWarehouseName); + setIfPresent(request.domesticCarrier(), order::setDomesticCarrier); + setIfPresent(request.domesticTrackingNo(), order::setDomesticTrackingNo); + setIfPresent(request.overseasCarrier(), order::setOverseasCarrier); + setIfPresent(request.overseasTrackingNo(), order::setOverseasTrackingNo); + if (request.estimatedArrivalAt() != null) order.setEstimatedArrivalAt(request.estimatedArrivalAt()); + setIfPresent(request.remark(), order::setRemark); + touch(order, LocalDateTime.now()); + orders.updateById(order); + return detail(customerId, orderNo); + } + + @Transactional + public OverseasProcurementResponses.Detail addOutbound(String customerId, String orderNo, String operatorId, + OverseasProcurementRequests.AddOutbound request) { + OverseasProcurementOrder order = lock(customerId, orderNo); + if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can ship stock"); + if (orders.addOutbound(order.getId(), customerId, request.quantity()) != 1) { + throw conflict("Outbound quantity exceeds current procurement inventory"); + } + OverseasProcurementOutbound outbound = new OverseasProcurementOutbound(); + outbound.setId(IdWorker.getId()); + outbound.setOrderId(order.getId()); + outbound.setCustomerId(customerId); + outbound.setQuantity(request.quantity()); + outbound.setOutboundNo(request.outboundNo().trim()); + outbound.setCarrier(trim(request.carrier())); + outbound.setTrackingNo(trim(request.trackingNo())); + outbound.setOutboundAt(request.outboundAt() == null ? LocalDateTime.now() : request.outboundAt()); + outbound.setRemark(trim(request.remark())); + outbound.setCreatedBy(operatorId); + outbound.setCreatedAt(LocalDateTime.now()); + try { + outbounds.insert(outbound); + } catch (DuplicateKeyException exception) { + throw conflict("Outbound number already exists for this procurement order"); + } + return detail(customerId, orderNo); + } + + private OverseasProcurementResponses.Detail replay(String customerId, OverseasProcurementOrder order, String hash) { + if (!hash.equals(order.getRequestHash())) { + throw conflict("Idempotency key was reused with different procurement data"); + } + return detail(customerId, order.getOrderNo()); + } + + private OverseasProcurementOrder lock(String customerId, String orderNo) { + OverseasProcurementOrder order = orders.lockNo(customerId, orderNo); + if (order == null) throw notFound("Overseas procurement order not found"); + return order; + } + + private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { + long inbound = value(order.getInboundQuantity()); + long outbound = value(order.getOutboundQuantity()); + return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), + order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), + order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, + inbound - outbound, order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + } + + private OverseasProcurementResponses.Outbound outbound(OverseasProcurementOutbound value) { + return new OverseasProcurementResponses.Outbound(value.getId(), value(value.getQuantity()), value.getOutboundNo(), + value.getCarrier(), value.getTrackingNo(), value.getOutboundAt(), value.getRemark(), value.getCreatedBy(), + value.getCreatedAt()); + } + + private void history(OverseasProcurementOrder order, String from, String to, String operator, + String remark, LocalDateTime now) { + OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); + history.setId(IdWorker.getId()); + history.setOrderId(order.getId()); + history.setCustomerId(order.getCustomerId()); + history.setFromStatus(from); + history.setToStatus(to); + history.setOperatorId(operator); + history.setRemark(trim(remark)); + history.setCreatedAt(now); + histories.insert(history); + } + + private void validateQuery(OverseasProcurementRequests.Query query) { + if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status().trim().toUpperCase())) { + throw badRequest("Unsupported procurement status"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) throw badRequest("createdFrom must not be after createdTo"); + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.writeValueAsBytes(value); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("Cannot hash procurement request", exception); + } + } + + private static void touch(OverseasProcurementOrder order, LocalDateTime now) { + order.setVersion(value(order.getVersion()) + 1); + order.setUpdatedAt(now); + } + + private static long value(Long value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String defaultCurrency(String value) { return trim(value) == null ? "USD" : value.trim().toUpperCase(); } + private static void setIfText(String value, java.util.function.Consumer setter) { + if (StringUtils.hasText(value)) setter.accept(value.trim()); + } + private static void setIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) setter.accept(trim(value)); + } + private static ResponseStatusException badRequest(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } + private static ResponseStatusException notFound(String message) { + return new ResponseStatusException(HttpStatus.NOT_FOUND, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java new file mode 100644 index 000000000..ab033a5d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/RelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.mapper") +public class RelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java new file mode 100644 index 000000000..349c89f69 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.relabel.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.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/relabel-applications") +public class RelabelApplicationController { + @Resource private RelabelApplicationService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { + return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); + } + @PutMapping("/{relabelNo}/status") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String relabelNo, + @Valid @RequestBody RelabelRequests.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(scope(user, customerId), relabelNo, operator(user), request)); + } + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java new file mode 100644 index 000000000..32ddaea7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.relabel.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelRequests { + private RelabelRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String customerOrderNo, + @NotBlank @Size(max = 128) String overseasWarehouseId, + @NotNull LocalDateTime labelDeadline, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 64) String contactPhone, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @Size(max = 128) String productImageFileId, + @Size(max = 255) String productImageFileName, + @Size(max = 1000) String productImageUrl, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int totalPackageCount, + @Positive int notifiedPackageCount, + @NotBlank @Size(max = 128) String shipmentNo, + @Size(max = 255) String trackingCode, + @NotBlank @Size(max = 128) String newSkuCode, + @Size(max = 1000) String labelRequirement, + @Size(max = 128) String relabelFileId, + @Size(max = 255) String relabelFileName, + @Size(max = 1000) String relabelFileUrl + ) {} + + public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo, + String customerOrderNo, String skuCode, String status, + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record ChangeStatus(@NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java new file mode 100644 index 000000000..f148026a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class RelabelResponses { + private RelabelResponses() {} + + public record Summary(Long id, String relabelNo, Long lastlegOrderId, String lastlegOrderNo, + String customerOrderNo, String overseasWarehouseId, LocalDateTime labelDeadline, + String contactName, String contactPhone, String status, int itemCount, + int totalPackageCount, int notifiedPackageCount, String skuCodes, + LocalDateTime createdAt) {} + public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName, + String productImageUrl, String productNameZh, int totalPackageCount, + int notifiedPackageCount, String shipmentNo, String trackingCode, + String newSkuCode, String labelRequirement, String relabelFileId, + String relabelFileName, String relabelFileUrl) {} + public record History(String fromStatus, String toStatus, String operatorId, + String remark, LocalDateTime createdAt) {} + public record Detail(Summary application, String remark, List items, List history) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java new file mode 100644 index 000000000..607c2f25f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.relabel.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_relabel_application") +public class RelabelApplication { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String relabelNo; + private Long lastlegOrderId; + private String customerOrderNo; + private String overseasWarehouseId; + private LocalDateTime labelDeadline; + private String contactName; + private String contactPhone; + private String remark; + private String status; + private Integer itemCount; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java new file mode 100644 index 000000000..5dde51a26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.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_relabel_item") +public class RelabelItem { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String skuCode; + private String productImageFileId; + private String productImageFileName; + private String productImageUrl; + private String productNameZh; + private Integer totalPackageCount; + private Integer notifiedPackageCount; + private String shipmentNo; + private String trackingCode; + private String newSkuCode; + private String labelRequirement; + private String relabelFileId; + private String relabelFileName; + private String relabelFileUrl; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java new file mode 100644 index 000000000..d68d2dda5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.relabel.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_relabel_status_history") +public class RelabelStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long applicationId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java new file mode 100644 index 000000000..692276aca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface RelabelApplicationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1") + RelabelApplication findNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + RelabelApplication findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_relabel_application WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} LIMIT 1 FOR UPDATE") + RelabelApplication lockNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java new file mode 100644 index 000000000..1e9b91c39 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java new file mode 100644 index 000000000..740e3b61c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RelabelStatusHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java new file mode 100644 index 000000000..8a3fe2efa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -0,0 +1,264 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RelabelApplicationService { + public static final String PENDING_PROCESSING = "PENDING_PROCESSING"; + private static final Set STATUSES = Set.of(PENDING_PROCESSING, "PROCESSING", "COMPLETED", "REJECTED", "CANCELLED"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private RelabelStatusHistoryMapper histories; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; + + @Transactional + public RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request) { + int total = 0; + int notified = 0; + for (RelabelRequests.Item item : request.items()) { + if (item.notifiedPackageCount() > item.totalPackageCount()) { + throw badRequest("Notified relocation packages cannot exceed total packages for SKU " + item.skuCode()); + } + total = Math.addExact(total, item.totalPackageCount()); + notified = Math.addExact(notified, item.notifiedPackageCount()); + } + String requestHash = hash(request); + RelabelApplication replay = applications.findKey(customerId, request.idempotencyKey().trim()); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with different relabel data"); + return detail(customerId, replay.getRelabelNo()); + } + LocalDateTime now = LocalDateTime.now(); + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); + order.setOrderNo(orderNoGenerator.next()); + order.setCustomerId(customerId); + order.setOrderType("RELABEL_APPLICATION"); + order.setInboundCode(relabelNo); + order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId(relabelNo); + order.setSourceOrderNo(request.customerOrderNo().trim()); + order.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + order.setPalletCount(0); + order.setPackageCount(total); + order.setPickedPackageCount(0); + order.setShippedPackageCount(0); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setSubOrderStatus("RELABEL:" + PENDING_PROCESSING); + order.setRelabelOrderNo(relabelNo); + order.setRelabelStatus(PENDING_PROCESSING); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("CUSTOMER_RELABEL:" + relabelNo); + order.setSourceLoadingPlanId("RELABEL_APPLICATION"); + order.setSourcePayloadHash("sha256:" + requestHash); + order.setCreatedAt(now); + order.setUpdatedAt(now); + orders.insert(order); + + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); + application.setCustomerId(customerId); + application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); + application.setCustomerOrderNo(request.customerOrderNo().trim()); + application.setOverseasWarehouseId(request.overseasWarehouseId().trim()); + application.setLabelDeadline(request.labelDeadline()); + application.setContactName(request.contactName().trim()); + application.setContactPhone(request.contactPhone().trim()); + application.setRemark(trim(request.remark())); + application.setStatus(PENDING_PROCESSING); + application.setItemCount(request.items().size()); + application.setTotalPackageCount(total); + application.setNotifiedPackageCount(notified); + application.setIdempotencyKey(request.idempotencyKey().trim()); + application.setRequestHash(requestHash); + application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); + application.setUpdatedAt(now); + applications.insert(application); + + for (RelabelRequests.Item source : request.items()) insertItem(customerId, applicationId, source, now); + insertHistory(customerId, applicationId, null, PENDING_PROCESSING, operatorId, "Relabel application submitted", now); + insertOrderHistory(order.getId(), null, order.getCurrentStatus(), "Relabel application submitted", now); + return detail(customerId, relabelNo); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, RelabelRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw badRequest("createdFrom must not be after createdTo"); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(RelabelApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) + .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) + .eq(StringUtils.hasText(query.status()), RelabelApplication::getStatus, query.status()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(StringUtils.hasText(query.skuCode()), + "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", + query.skuCode()) + .orderByDesc(RelabelApplication::getCreatedAt); + Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); + List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); + return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true) + public RelabelResponses.Detail detail(String customerId, String relabelNo) { + RelabelApplication application = require(customerId, relabelNo); + List detailItems = itemList(application.getId()); + List statusHistory = histories.selectList(new LambdaQueryWrapper() + .eq(RelabelStatusHistory::getCustomerId, customerId) + .eq(RelabelStatusHistory::getApplicationId, application.getId()) + .orderByAsc(RelabelStatusHistory::getCreatedAt)); + return new RelabelResponses.Detail(summary(application, detailItems), application.getRemark(), + detailItems.stream().map(this::item).toList(), statusHistory.stream().map(value -> + new RelabelResponses.History(value.getFromStatus(), value.getToStatus(), value.getOperatorId(), + value.getRemark(), value.getCreatedAt())).toList()); + } + + @Transactional + public RelabelResponses.Detail changeStatus(String customerId, String relabelNo, String operatorId, + RelabelRequests.ChangeStatus request) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + if (application == null) throw notFound("Relabel application not found"); + String next = request.status().trim().toUpperCase(); + if (!STATUSES.contains(next)) throw badRequest("Unsupported relabel status"); + if (next.equals(application.getStatus())) return detail(customerId, relabelNo); + validateTransition(application.getStatus(), next); + String before = application.getStatus(); + LocalDateTime now = LocalDateTime.now(); + application.setStatus(next); + application.setUpdatedAt(now); + applications.updateById(application); + LastlegOrder order = orders.selectById(application.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + String orderBefore = order.getCurrentStatus(); + String mainStatus = STATUSES.contains(orderBefore) ? "PENDING_ASSIGNMENT" : orderBefore; + // A return-relabel tail order joins the normal inbound queue after labels are finished. + String orderStatus = switch (next) { + case "COMPLETED" -> "RETURN_RELABEL".equals(order.getOrderType()) + ? "PENDING_ASSIGNMENT" : "COMPLETED"; + case "REJECTED", "CANCELLED" -> "CANCELLED"; + default -> mainStatus; + }; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("RELABEL:" + next); + order.setRelabelStatus(next); + order.setUpdatedAt(now); + orders.updateById(order); + insertHistory(customerId, application.getId(), before, next, operatorId, request.remark(), now); + if (!Objects.equals(orderBefore, orderStatus)) { + insertOrderHistory(order.getId(), orderBefore, orderStatus, request.remark(), now); + } + return detail(customerId, relabelNo); + } + + private void insertItem(String customerId, long applicationId, RelabelRequests.Item source, LocalDateTime now) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductImageFileId(trim(source.productImageFileId())); + item.setProductImageFileName(trim(source.productImageFileName())); item.setProductImageUrl(trim(source.productImageUrl())); + item.setProductNameZh(source.productNameZh().trim()); item.setTotalPackageCount(source.totalPackageCount()); + item.setNotifiedPackageCount(source.notifiedPackageCount()); item.setShipmentNo(source.shipmentNo().trim()); + item.setTrackingCode(trim(source.trackingCode())); item.setNewSkuCode(source.newSkuCode().trim()); + item.setLabelRequirement(trim(source.labelRequirement())); item.setRelabelFileId(trim(source.relabelFileId())); + item.setRelabelFileName(trim(source.relabelFileName())); item.setRelabelFileUrl(trim(source.relabelFileUrl())); + item.setCreatedAt(now); items.insert(item); + } + + private RelabelResponses.Summary summary(RelabelApplication value, List detailItems) { + LastlegOrder order = orders.selectById(value.getLastlegOrderId()); + String skuCodes = detailItems.stream().map(RelabelItem::getSkuCode).filter(Objects::nonNull).distinct().collect(Collectors.joining(",")); + return new RelabelResponses.Summary(value.getId(), value.getRelabelNo(), value.getLastlegOrderId(), + order == null ? null : order.getOrderNo(), value.getCustomerOrderNo(), value.getOverseasWarehouseId(), + value.getLabelDeadline(), value.getContactName(), value.getContactPhone(), value.getStatus(), + Objects.requireNonNullElse(value.getItemCount(), detailItems.size()), + Objects.requireNonNullElse(value.getTotalPackageCount(), 0), + Objects.requireNonNullElse(value.getNotifiedPackageCount(), 0), skuCodes, value.getCreatedAt()); + } + + private RelabelResponses.Item item(RelabelItem value) { + return new RelabelResponses.Item(value.getId(), value.getSkuCode(), value.getProductImageFileId(), + value.getProductImageFileName(), value.getProductImageUrl(), value.getProductNameZh(), + value.getTotalPackageCount(), value.getNotifiedPackageCount(), value.getShipmentNo(), + value.getTrackingCode(), value.getNewSkuCode(), value.getLabelRequirement(), value.getRelabelFileId(), + value.getRelabelFileName(), value.getRelabelFileUrl()); + } + + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) + .orderByAsc(RelabelItem::getId)); + } + private RelabelApplication require(String customerId, String relabelNo) { + RelabelApplication value = applications.findNo(customerId, relabelNo); + if (value == null) throw notFound("Relabel application not found"); + return value; + } + private void insertHistory(String customerId, Long id, String from, String to, String operator, + String remark, LocalDateTime now) { + RelabelStatusHistory value = new RelabelStatusHistory(); value.setId(IdWorker.getId()); value.setCustomerId(customerId); + value.setApplicationId(id); value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(trim(operator)); + value.setRemark(trim(remark)); value.setCreatedAt(now); histories.insert(value); + } + private void insertOrderHistory(Long id, String from, String to, String remark, LocalDateTime now) { + LastlegOrderStatusHistory value = new LastlegOrderStatusHistory(); value.setOrderId(id); value.setFromStatus(from); + value.setToStatus(to); value.setEventId("RELABEL_APPLICATION"); value.setRemark(trim(remark)); value.setCreatedAt(now); + orderHistories.insert(value); + } + private static void validateTransition(String from, String to) { + boolean allowed = PENDING_PROCESSING.equals(from) && Set.of("PROCESSING", "REJECTED", "CANCELLED").contains(to) + || "PROCESSING".equals(from) && Set.of("COMPLETED", "REJECTED", "CANCELLED").contains(to); + if (!allowed) throw conflict("Relabel status cannot change from " + from + " to " + to); + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java new file mode 100644 index 000000000..b3cd17d05 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java @@ -0,0 +1,5 @@ +/** + * Tenant-scoped aggregate queries and multi-mapper persistence composition. + * / 租户范围聚合查询及多 Mapper 持久化组合。 + */ +package com.baiyue.lastleg.repository; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..ba4ef399b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final String expectedToken; + public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) { this.expectedToken=expectedToken; } + public void verify(String actualToken) { + if(expectedToken==null||expectedToken.isBlank()) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"内部事件接收令牌未配置"); + byte[] expected=expectedToken.getBytes(StandardCharsets.UTF_8); + byte[] actual=actualToken==null?new byte[0]:actualToken.getBytes(StandardCharsets.UTF_8); + if(!MessageDigest.isEqual(expected,actual)) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部服务令牌无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java new file mode 100644 index 000000000..86b89766f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Component; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Component +public class LastlegOrderNoGenerator { + public String next() { + return "LMO" + IdWorker.getIdStr(); + } + + /** The physical container number is the tail-leg business order grouping boundary. */ + public String forContainerNo(String containerNo) { + if (containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("containerNo is required"); + } + String normalized = containerNo.trim(); + if (normalized.matches("[0-9]{1,29}")) { + return "LMO" + normalized; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(normalized.getBytes(StandardCharsets.UTF_8)); + String digits = new BigInteger(1, digest).toString(); + return "LMO" + digits.substring(0, Math.min(29, digits.length())); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java new file mode 100644 index 000000000..414d2067f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -0,0 +1,202 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class LastlegOrderService { + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String DESTINATION_ASSIGNED = "DESTINATION_ASSIGNED"; + public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; + + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private DropshipOrderProfileMapper dropshipProfiles; + @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; + @Resource private DropshipCartonMapper dropshipCartons; + + public PageResult page(String customerId, LastlegOrderRequests.Query query) { + Page page = orderMapper.selectPage( + new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); + return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + public LastlegOrderDetailResponse detail(String customerId, long orderId) { + LastlegOrder order = findOwned(customerId, orderId); + List cargo = detailMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + List history = historyMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderStatusHistory::getOrderId, orderId) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); + List pallets = dropshipPallets(customerId, order); + return new LastlegOrderDetailResponse(order, cargo, history, pallets); + } + + private List dropshipPallets(String customerId, LastlegOrder order) { + if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of(); + var profiles = dropshipProfiles.listOrder(customerId, order.getId()); + if (profiles.isEmpty()) return List.of(); + Map> skuByProfile = dropshipSkuInventory + .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); + Map> cartonsByProfile = dropshipCartons + .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId)); + return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet( + profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(), + profile.getInventoryPositionId(), profile.getStatus(), + skuByProfile.getOrDefault(profile.getId(), List.of()), + cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList(); + } + + @Transactional + public int transferDestination(String customerId, LastlegOrderRequests.TransferDestination request) { + List ids = request.orderIds().stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); + } + List orders = orderMapper.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, ids).last("FOR UPDATE")); + if (orders.size() != ids.size()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more tail-leg orders were not found"); + } + for (LastlegOrder order : orders) { + ensureTransferable(order); + } + LocalDateTime now = LocalDateTime.now(); + for (LastlegOrder order : orders) { + String fromStatus = order.getCurrentStatus(); + order.setTransferWarehouseId(request.warehouseId()); + order.setTransferWarehouseCode(request.warehouseCode()); + order.setTransferWarehouseName(request.warehouseName()); + order.setCurrentStatus(DESTINATION_ASSIGNED); + order.setUpdatedAt(now); + orderMapper.updateById(order); + + LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); + detailUpdate.setDestinationWarehouseId(request.warehouseId()); + detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); + detailUpdate.setDestinationWarehouseName(request.warehouseName()); + detailMapper.update(detailUpdate, new LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, order.getId())); + + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); + history.setFromStatus(fromStatus); + history.setToStatus(DESTINATION_ASSIGNED); + history.setRemark(StringUtils.hasText(request.remark()) + ? request.remark() : "Destination warehouse assigned: " + request.warehouseCode()); + history.setCreatedAt(now); + historyMapper.insert(history); + } + return orders.size(); + } + + public byte[] exportCsv(String customerId, LastlegOrderRequests.Query query) { + List orders = orderMapper.selectList(filter(customerId, query) + .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); + List lines = new ArrayList<>(); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); + for (LastlegOrder order : orders) { + lines.add(String.join(",", List.of( + csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()), + csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), + csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), + csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()), + csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) + - Objects.requireNonNullElse(order.getShippedPackageCount(), 0))), + csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), + csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()), + csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), + csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), + csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); + } + return ("\uFEFF" + String.join("\r\n", lines)).getBytes(StandardCharsets.UTF_8); + } + + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + } + return new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) + .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) + .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) + .eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status()) + .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, + query.transferWarehouseCode()) + .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) + .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) + .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + } + + private LastlegOrder findOwned(String customerId, long orderId) { + LastlegOrder order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getId, orderId)); + if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found"); + return order; + } + + private void ensureTransferable(LastlegOrder order) { + if (StringUtils.hasText(order.getInboundOrderNo()) || INBOUND_PLANNED.equals(order.getCurrentStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Destination warehouse cannot be changed after inbound plan creation: " + order.getId()); + } + } + + private LastlegOrderSummary summary(LastlegOrder o) { + return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), o.getSourceInboundId(), + o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), + o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), + o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(), + o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0) + - Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(), + o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(), + o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), + o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt()); + } + + private static String csv(Object value) { + if (value == null) return ""; + String text = String.valueOf(value); + if (!text.isEmpty() && "=+-@".indexOf(text.charAt(0)) >= 0) text = "'" + text; + return '"' + text.replace("\"", "\"\"") + '"'; + } +} 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 new file mode 100644 index 000000000..49fa9caef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -0,0 +1,203 @@ +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.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.ArrayList; +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; + +@Service +public class LoadingCompletedConsumer { + @Resource private LastlegEventInboxMapper inboxMapper; + @Resource private LastlegOrderMapper orderMapper; + @Resource private LastlegOrderDetailMapper detailMapper; + @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private ObjectMapper objectMapper; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private DropshipOrderService dropshipOrders; + + @Transactional + public int consume(EventEnvelope envelope) { + envelope.requireValid(); + if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1) + throw new IllegalArgumentException("不支持的尾程交接事件"); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) + throw new IllegalArgumentException("尾程交接事件区域无效"); + if (!"LOADING_PLAN".equals(envelope.aggregateType())) + throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); + if(payload.containerNo()==null||payload.containerNo().isBlank()) + throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); + if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); + if(existing!=null) { + if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); + return countOrders(envelope,payload.containerNo()); + } + + LastlegEventInbox inbox=new LastlegEventInbox(); + inbox.setEventId(envelope.eventId()); inbox.setCustomerId(envelope.tenantId()); inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); + inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); + try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + return countOrders(envelope,payload.containerNo()); + } + + String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + JsonNode rawOrders = envelope.payload().path("orders"); + for (int index = 0; index < payload.orders().size(); index++) { + var source = payload.orders().get(index); + JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); + createOrder(envelope, payload, source, orderNo, rawSource); + } + inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); + return payload.orders().size(); + } + + private FirstLegLoadingPlanCompletedV1 parsePayload(EventEnvelope envelope) { + try { + ObjectMapper compatibleMapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); + } + catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } + } + + private int countOrders(EventEnvelope envelope,String containerNo) { + return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); + } + + private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, + FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { + String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); + LastlegOrder existing=orderMapper.findSource(event.tenantId(),event.sourceRegion().name(),source.sourceInboundId()); + if(existing!=null) { + if(!Objects.equals(existing.getSourcePayloadHash(),orderHash)) throw new IllegalStateException("来源入仓单快照冲突: "+source.sourceInboundId()); + return; + } + LastlegOrder order=new LastlegOrder(); + String orderType = source.orderType(); + String customerId = source.customerId() == null || source.customerId().isBlank() + ? event.tenantId() : source.customerId(); + if (!event.tenantId().equals(customerId)) { + throw new IllegalArgumentException("Order customerId does not match the trusted event customer"); + } + List dropshipPallets = "ONE_CLICK_DROPSHIP".equals(orderType) + ? pallets(source, rawSource) : List.of(); + if ("ONE_CLICK_DROPSHIP".equals(orderType) && dropshipPallets.isEmpty()) { + throw new IllegalArgumentException("One-click dropship event has no pallet snapshot"); + } + order.setId(IdWorker.getId()); order.setOrderNo(orderNo); order.setCustomerId(customerId); order.setOrderType(orderType); + order.setInboundCode(source.inboundCode()); order.setSourceRegion(event.sourceRegion().name()); + order.setSourceInboundId(source.sourceInboundId()); order.setRootInboundId(source.rootInboundId()); + order.setSourceOrderId(source.sourceOrderId()); order.setSourceOrderNo(source.sourceOrderNo()); order.setRootOrderId(source.rootOrderId()); + order.setOverseasWarehouseId(payload.warehouseId()); order.setOverseasWarehouseName(payload.warehouseName()); + order.setFbaWarehouseCode(source.destinationWarehouseCode()); order.setContainerNo(payload.containerNo()); + order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); + order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); + order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) + .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); + order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); + try { + orderMapper.insert(order); + } catch (DuplicateKeyException exception) { + LastlegOrder raced = orderMapper.findSource(event.tenantId(), event.sourceRegion().name(), + source.sourceInboundId()); + if (raced != null && Objects.equals(raced.getSourcePayloadHash(), orderHash)) return; + throw exception; + } + + for(var item:source.items()) { + LastlegOrderDetail detail=new LastlegOrderDetail(); + detail.setOrderId(order.getId()); detail.setSourceInboundDetailId(item.sourceInboundDetailId()); detail.setSkuCode(item.skuCode()); + detail.setProductNameZh(item.productNameZh()); detail.setProductNameEn(item.productNameEn()); detail.setPackageCount(item.packageCount()); + detail.setProductQuantity(item.productQuantity()); detail.setPalletCount(item.palletCount()); detail.setGrossWeightKg(item.grossWeightKg()); + detail.setNetWeightKg(item.netWeightKg()); detail.setDimensionalWeightKg(item.dimensionalWeightKg()); detail.setVolumeCbm(item.volumeCbm()); + detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); + detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); + detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + } + LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); + history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); + history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + if ("ONE_CLICK_DROPSHIP".equals(orderType)) { + long warehouseId; + try { warehouseId = Long.parseLong(payload.warehouseId()); } + catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } + dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, + event.eventId() + ":DROPSHIP", dropshipPallets)); + } + } + + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { + if (source.pallets() != null && !source.pallets().isEmpty()) { + return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), + pallet.skuPackages().stream().map(sku -> new DropshipRequests.SkuPackages( + sku.skuCode(), sku.productName(), sku.packageCount(), sku.productQuantity(), + sku.cartonMarks())).toList())).toList(); + } + return legacyPallets(rawSource); + } + + private List legacyPallets(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("pallets"); + if (nodes.isArray()) { + for (JsonNode pallet : nodes) { + String palletNo = pallet.path("palletNo").asText(pallet.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(pallet); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + } + return result; + } + String palletNo = source.path("palletNo").asText(source.path("sourcePalletNo").asText(null)); + List skuRows = skuPackages(source); + if (palletNo != null && !palletNo.isBlank() && !skuRows.isEmpty()) { + result.add(new DropshipRequests.Pallet(palletNo, skuRows)); + } + return result; + } + + private List skuPackages(JsonNode source) { + List result = new ArrayList<>(); + JsonNode nodes = source.path("skuPackages"); + if (!nodes.isArray()) nodes = source.path("items"); + if (!nodes.isArray()) return result; + for (JsonNode item : nodes) { + String sku = item.path("skuCode").asText(null); + long packageCount = item.path("packageCount").asLong(0); + long productQuantity = item.path("productQuantity").asLong(0); + JsonNode markNodes = item.path("cartonMarks"); + if (!markNodes.isArray()) markNodes = item.path("boxMarks"); + if (!markNodes.isArray()) markNodes = item.path("cartonNos"); + List marks = new ArrayList<>(); + if (markNodes.isArray()) { + for (JsonNode mark : markNodes) if (!mark.asText().isBlank()) marks.add(mark.asText()); + } + if (sku != null && !sku.isBlank() && packageCount > 0 && productQuantity > 0 && !marks.isEmpty()) { + String productName = item.path("productName").asText(item.path("productNameZh").asText(null)); + result.add(new DropshipRequests.SkuPackages(sku, productName, packageCount, productQuantity, marks)); + } + } + return result; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java new file mode 100644 index 000000000..01f4a18ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Business orchestration and transaction boundaries for last-leg delivery. + * / 尾程配送业务编排与事务边界。 + */ +package com.baiyue.lastleg.service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java new file mode 100644 index 000000000..17bc09bb8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/TransferMapperConfiguration.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.transfer; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; +@Configuration @MapperScan("com.baiyue.lastleg.transfer.mapper") public class TransferMapperConfiguration {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java new file mode 100644 index 000000000..6165b04fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.transfer.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.service.WarehouseTransferService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/warehouse-transfers") +public class WarehouseTransferController { + public static final String MANAGE_PERMISSION = "lastleg:warehouse-transfer:manage"; + @Resource private WarehouseTransferService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid @RequestBody WarehouseTransferRequests.Create request) { + return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); + } + + @GetMapping("/{transferNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo) { + return ApiResponse.success(service.detail(scope(user, customerId), transferNo)); + } + + @PostMapping("/{transferNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Review request) { + requireApprovalPermission(user); + return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/allocations") + public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Allocate request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/outbound") + public ApiResponse outbound(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Outbound request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request)); + } + + @PostMapping("/{transferNo}/receipts") + public ApiResponse receive(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @PathVariable String transferNo, + @Valid @RequestBody WarehouseTransferRequests.Receipt request) { + requirePermission(user, MANAGE_PERMISSION); + return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request)); + } + + private void requireApprovalPermission(AuthenticatedUser user) { + if (user == null || user.permissions() == null + || !user.permissions().contains("lastleg:warehouse-transfer:approve")) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer approval permission is required"); + } + } + + private void requirePermission(AuthenticatedUser user, String permission) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); + } + } + + private String scope(AuthenticatedUser user, String requested) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { + if (requested == null || requested.isBlank() || "0".equals(requested)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); + } + return requested.trim(); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java new file mode 100644 index 000000000..efa13b0c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.transfer.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferRequests { + private WarehouseTransferRequests() {} + + public record Create( + @NotBlank @Size(max = 128) String inboundCode, + @NotEmpty @Size(max = 200) List<@NotNull Long> sourceLastlegOrderIds, + @NotBlank @Size(max = 128) String operatingWarehouseId, + @NotBlank @Size(max = 128) String fbaWarehouseCode, + @NotBlank @Size(max = 1000) String deliveryAddress, + @Positive int packageCount, + @NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm, + @NotBlank @Size(max = 64) String deliveryMethod, + @NotNull LocalDateTime estimatedArrivalAt, + @NotBlank @Size(max = 128) String fbaShipmentNo, + @Size(max = 255) String trackingNo, + @Size(max = 2000) String fbaBoxNos, + @Size(max = 1000) String remark, + boolean needRelabel, + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Item> items + ) {} + + public record Review(@NotBlank @Size(max = 32) String decision, + @Size(max = 1000) String remark) {} + + public record Allocate( + @NotBlank @Size(max = 255) String idempotencyKey, + @NotEmpty @Size(max = 500) List<@Valid Allocation> allocations + ) {} + + public record Allocation( + @NotNull Long inventoryPositionId, + @NotNull Long palletId, + @Positive int packageCount + ) {} + + public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Receipt( + @NotNull Long sourceInventoryPositionId, + @NotBlank @Size(max = 128) String destinationOverseasWarehouseId, + @Size(max = 128) String destinationOverseasWarehouseCode, + @Size(max = 255) String destinationOverseasWarehouseName, + @Size(max = 128) String destinationWarehouseId, + @NotBlank @Size(max = 128) String destinationWarehouseCode, + @Size(max = 255) String destinationWarehouseName, + @NotNull Long destinationPalletId, + @NotBlank @Size(max = 128) String destinationPalletNo, + @Size(max = 128) String batchNo, + Long warehouseLocationId, + @Size(max = 128) String warehouseLocationCode, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark + ) {} + + public record Item( + @NotBlank @Size(max = 128) String skuCode, + @NotBlank @Size(max = 255) String productNameZh, + @Positive int packageCount, + @Size(max = 255) String shippingMark + ) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java new file mode 100644 index 000000000..38d8bcf12 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.transfer.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class WarehouseTransferResponses { + private WarehouseTransferResponses() {} + public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {} + public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {} + public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode, + Long palletId, String palletNo, int packageCount, String status, + Long destinationInventoryPositionId) {} + public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, + Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, + boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, + String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, + int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, + String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark, + List sourceOrders, List items, List allocations, + LocalDateTime outboundAt, LocalDateTime createdAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java new file mode 100644 index 000000000..1133aa030 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_inventory_allocation") +public class WarehouseTransferInventoryAllocation { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long inventoryPositionId; + private Long inventoryBalanceId; + private Long inboundOrderId; + private String inboundCode; + private Long palletId; + private String palletNo; + private Integer packageCount; + private String status; + private String activePositionKey; + private LocalDateTime allocatedAt; + private LocalDateTime shippedAt; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java new file mode 100644 index 000000000..6ca13d301 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_item") +public class WarehouseTransferItem { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private String skuCode; + private String productNameZh; + private Integer packageCount; + private String shippingMark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java new file mode 100644 index 000000000..56e8acae9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_order") +public class WarehouseTransferOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String transferNo; + private Long inboundOrderId; + private String inboundCode; + private Long lastlegOrderId; + private String lastlegOrderNo; + private String operatingWarehouseId; + private String fbaWarehouseCode; + private String deliveryAddress; + private Integer packageCount; + private String deliveryMethod; + private LocalDateTime estimatedArrivalAt; + private String fbaShipmentNo; + private String trackingNo; + private String fbaBoxNos; + private String remark; + private Boolean needRelabel; + private Long relabelApplicationId; + private String relabelNo; + private String status; + private String allocationIdempotencyKey; + private String allocationRequestHash; + private String outboundIdempotencyKey; + private String outboundRequestHash; + private LocalDateTime outboundAt; + private String idempotencyKey; + private String requestHash; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java new file mode 100644 index 000000000..ac50dafb5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_receipt") +public class WarehouseTransferReceipt { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private Long allocationId; + private String customerId; + private Long sourceInventoryPositionId; + private Long destinationInventoryBalanceId; + private Long destinationInventoryPositionId; + private Integer packageCount; + private String idempotencyKey; + private String requestHash; + private String inventoryOperationKey; + private String createdBy; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java new file mode 100644 index 000000000..c760c7000 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.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_warehouse_transfer_source_order") +public class WarehouseTransferSourceOrder { + @TableId(type = IdType.INPUT) private Long id; + private Long transferOrderId; + private String customerId; + private Long sourceLastlegOrderId; + private String sourceLastlegOrderNo; + private Long inboundOrderId; + private String inboundCode; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java new file mode 100644 index 000000000..59e14637c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.transfer.mapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface InboundSnapshotMapper { + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND (inbound_code=#{code} OR source_inbound_id=#{code}) LIMIT 1") + Long findId(@Param("customerId") String customerId, @Param("code") String code); + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND source_inbound_id=#{sourceInboundId} LIMIT 1") + Long findBySourceInboundId(@Param("customerId") String customerId, @Param("sourceInboundId") String sourceInboundId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java new file mode 100644 index 000000000..858e80d15 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WarehouseTransferInventoryAllocationMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE") + List lockTransfer(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId); + + @Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} " + + "AND warehouse_pallet_id=#{palletId} AND status='ACTIVE'") + int countActiveDispatchLock(@Param("customerId") String customerId, @Param("palletId") Long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java new file mode 100644 index 000000000..7de29cb2c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferItemMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java new file mode 100644 index 000000000..e062027c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface WarehouseTransferOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + WarehouseTransferOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1") + WarehouseTransferOrder findNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} LIMIT 1 FOR UPDATE") + WarehouseTransferOrder lockNo(@Param("customerId") String customerId, @Param("transferNo") String transferNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java new file mode 100644 index 000000000..24f8b3ff1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface WarehouseTransferReceiptMapper extends BaseMapper { + @Select("SELECT * FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customerId} " + + "AND transfer_order_id=#{transferOrderId} AND source_inventory_position_id=#{sourcePositionId} LIMIT 1") + WarehouseTransferReceipt findSource(@Param("customerId") String customerId, + @Param("transferOrderId") Long transferOrderId, + @Param("sourcePositionId") Long sourcePositionId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java new file mode 100644 index 000000000..a501a5110 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.transfer.mapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import org.apache.ibatis.annotations.Mapper; +@Mapper public interface WarehouseTransferSourceOrderMapper extends BaseMapper {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java new file mode 100644 index 000000000..5abcb23ce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -0,0 +1,536 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class WarehouseTransferService { + public static final String ORDER_TYPE = "WAREHOUSE_TRANSFER"; + public static final String RETURN_RELABEL_ORDER_TYPE = "RETURN_RELABEL"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + public static final String APPROVED = "APPROVED"; + public static final String REJECTED = "REJECTED"; + public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String OUTBOUNDED = "OUTBOUNDED"; + public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; + public static final String RECEIVED = "RECEIVED"; + + @Resource private WarehouseTransferOrderMapper transfers; + @Resource private WarehouseTransferItemMapper transferItems; + @Resource private WarehouseTransferSourceOrderMapper sourceOrders; + @Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Resource private WarehouseTransferReceiptMapper transferReceipts; + @Resource private InboundSnapshotMapper inboundSnapshots; + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; + @Resource private LastlegOrderStatusHistoryMapper orderHistories; + @Resource private RelabelApplicationMapper relabelApplications; + @Resource private RelabelItemMapper relabelItems; + @Resource private RelabelStatusHistoryMapper relabelHistories; + @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private InventoryCommandService inventory; + @Resource private InventoryCommandMapper inventoryCommands; + @Resource private ObjectMapper objectMapper; + + @Transactional + public WarehouseTransferResponses.Detail create(String customerId, String operatorId, + WarehouseTransferRequests.Create request) { + int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum(); + if (itemPackages != request.packageCount()) { + throw badRequest("Cargo item package total must equal transfer package count"); + } + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + WarehouseTransferOrder replay = transfers.findKey(customerId, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) { + throw conflict("Idempotency key was reused with different warehouse transfer data"); + } + return detail(customerId, replay.getTransferNo()); + } + + String inboundCode = request.inboundCode().trim(); + Long inboundOrderId = inboundSnapshots.findId(customerId, inboundCode); + if (inboundOrderId == null) throw notFound("Inbound order was not found for inboundCode " + inboundCode); + List selectedIds = request.sourceLastlegOrderIds().stream().distinct().toList(); + List selectedOrders = orders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, selectedIds)); + if (selectedOrders.size() != selectedIds.size()) { + throw notFound("One or more selected customer orders do not exist in the current tenant"); + } + + LocalDateTime now = LocalDateTime.now(); + long transferId = IdWorker.getId(); + String transferNo = "WTR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + transferId; + LastlegOrder order = createTailOrder(customerId, inboundOrderId, transferNo, request, requestHash, now); + + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(transferId); transfer.setCustomerId(customerId); transfer.setTransferNo(transferNo); + transfer.setInboundOrderId(inboundOrderId); transfer.setInboundCode(inboundCode); + transfer.setLastlegOrderId(order.getId()); transfer.setLastlegOrderNo(order.getOrderNo()); + transfer.setOperatingWarehouseId(request.operatingWarehouseId().trim()); + transfer.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); + transfer.setDeliveryAddress(request.deliveryAddress().trim()); transfer.setPackageCount(request.packageCount()); + transfer.setDeliveryMethod(request.deliveryMethod().trim()); transfer.setEstimatedArrivalAt(request.estimatedArrivalAt()); + transfer.setFbaShipmentNo(request.fbaShipmentNo().trim()); transfer.setTrackingNo(trim(request.trackingNo())); + transfer.setFbaBoxNos(trim(request.fbaBoxNos())); transfer.setRemark(trim(request.remark())); + transfer.setNeedRelabel(request.needRelabel()); transfer.setStatus(PENDING_REVIEW); + transfer.setIdempotencyKey(key); transfer.setRequestHash(requestHash); transfer.setCreatedBy(trim(operatorId)); + transfer.setCreatedAt(now); transfer.setUpdatedAt(now); + transfers.insert(transfer); + + for (LastlegOrder selectedOrder : selectedOrders) { + Long selectedInboundId = StringUtils.hasText(selectedOrder.getSourceInboundId()) + ? inboundSnapshots.findBySourceInboundId(customerId, selectedOrder.getSourceInboundId()) : null; + if (selectedInboundId == null) selectedInboundId = inboundSnapshots.findId(customerId, selectedOrder.getInboundCode()); + if (selectedInboundId == null) { + throw conflict("Selected order " + selectedOrder.getOrderNo() + " has no linked inbound order"); + } + WarehouseTransferSourceOrder link = new WarehouseTransferSourceOrder(); + link.setId(IdWorker.getId()); link.setTransferOrderId(transferId); link.setCustomerId(customerId); + link.setSourceLastlegOrderId(selectedOrder.getId()); link.setSourceLastlegOrderNo(selectedOrder.getOrderNo()); + link.setInboundOrderId(selectedInboundId); link.setInboundCode(selectedOrder.getInboundCode()); link.setCreatedAt(now); + sourceOrders.insert(link); + } + + for (int index = 0; index < request.items().size(); index++) { + WarehouseTransferRequests.Item source = request.items().get(index); + insertTransferItem(customerId, transferId, source, now); + insertOrderDetail(order.getId(), transferNo, index, source, request, now); + } + insertOrderHistory(order.getId(), null, PENDING_REVIEW, transferNo, + "Warehouse transfer submitted for review", now); + if (request.needRelabel()) createRelabel(customerId, operatorId, transfer, order, request, now); + return detail(customerId, transferNo); + } + + @Transactional(readOnly = true) + public WarehouseTransferResponses.Detail detail(String customerId, String transferNo) { + WarehouseTransferOrder transfer = transfers.findNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + List items = transferItems.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferItem::getCustomerId, customerId) + .eq(WarehouseTransferItem::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferItem::getId)) + .stream().map(value -> new WarehouseTransferResponses.Item(value.getSkuCode(), value.getProductNameZh(), + value.getPackageCount(), value.getShippingMark())).toList(); + List sources = sourceOrders.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId)) + .stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(), + value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList(); + Map receiptByAllocation = transferReceipts.selectList( + new LambdaQueryWrapper().eq(WarehouseTransferReceipt::getCustomerId, customerId) + .eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId())) + .stream().collect(Collectors.toMap(WarehouseTransferReceipt::getAllocationId, Function.identity())); + List allocations = inventoryAllocations.selectList( + new LambdaQueryWrapper() + .eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId) + .eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId()) + .orderByAsc(WarehouseTransferInventoryAllocation::getId)) + .stream().map(value -> { + WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId()); + return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(), + value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(), + value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId()); + }).toList(); + String relabelStatus = null; + if (transfer.getRelabelNo() != null) { + RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo()); + relabelStatus = relabel == null ? null : relabel.getStatus(); + } + LastlegOrder tailOrder = orders.selectById(transfer.getLastlegOrderId()); + return new WarehouseTransferResponses.Detail(transfer.getId(), transfer.getTransferNo(), transfer.getInboundOrderId(), + transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), + tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(), + tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(), + Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), + relabelStatus, + transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), + transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(), + transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations, + transfer.getOutboundAt(), transfer.getCreatedAt()); + } + + @Transactional + public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Review request) { + String decision = request.decision().trim().toUpperCase(); + String next = switch (decision) { + case "APPROVE", APPROVED -> APPROVED; + case "REJECT", REJECTED -> REJECTED; + default -> throw badRequest("Review decision must be APPROVE or REJECT"); + }; + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!PENDING_REVIEW.equals(transfer.getStatus())) { + if (next.equals(transfer.getStatus())) return detail(customerId, transferNo); + throw conflict("Warehouse transfer order was already reviewed"); + } + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Linked tail-leg order is missing"); + LocalDateTime now = LocalDateTime.now(); + String before = order.getCurrentStatus(); + transfer.setStatus(next); transfer.setUpdatedAt(now); transfers.updateById(transfer); + String orderStatus = APPROVED.equals(next) ? PENDING_ASSIGNMENT : "CANCELLED"; + order.setCurrentStatus(orderStatus); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + next); + order.setUpdatedAt(now); orders.updateById(order); + if (!java.util.Objects.equals(before, orderStatus)) { + insertOrderHistory(order.getId(), before, orderStatus, transferNo, + "Warehouse transfer reviewed: " + next, now); + } + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Allocate request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) { + if (!requestHash.equals(transfer.getAllocationRequestHash())) { + throw conflict("Allocation idempotency key was reused with different inventory positions"); + } + return detail(customerId, transferNo); + } + if (!APPROVED.equals(transfer.getStatus())) { + throw conflict("Only an approved warehouse transfer can allocate inventory"); + } + List requested = request.allocations().stream() + .sorted(java.util.Comparator.comparing(WarehouseTransferRequests.Allocation::inventoryPositionId)).toList(); + Set uniquePositions = new HashSet<>(); + long totalPackages = 0; + for (WarehouseTransferRequests.Allocation allocation : requested) { + if (!uniquePositions.add(allocation.inventoryPositionId())) { + throw badRequest("Inventory positions must be unique within a warehouse transfer"); + } + totalPackages = Math.addExact(totalPackages, allocation.packageCount()); + } + if (totalPackages != transfer.getPackageCount()) { + throw badRequest("Allocated package total must equal warehouse transfer package count"); + } + Set sourceInboundIds = sourceOrders.selectList(new LambdaQueryWrapper() + .eq(WarehouseTransferSourceOrder::getCustomerId, customerId) + .eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId())) + .stream().map(WarehouseTransferSourceOrder::getInboundOrderId).collect(Collectors.toSet()); + if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders"); + + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferRequests.Allocation source : requested) { + InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId()); + if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId()); + InventoryBalance balance = inventoryCommands.lockBalance(customerId, position.getBalanceId()); + if (balance == null) throw conflict("Inventory position balance is missing"); + if (!source.palletId().equals(position.getPalletId())) { + throw conflict("Inventory position does not belong to pallet " + source.palletId()); + } + if (inventoryAllocations.countActiveDispatchLock(customerId, position.getPalletId()) > 0) { + throw conflict("Inventory pallet is already locked by an active dispatch"); + } + if (!sourceInboundIds.contains(balance.getInboundOrderId())) { + throw conflict("Inventory position is not linked to a selected source inbound order"); + } + if (packages(position.getAvailablePackages()) < source.packageCount()) { + throw conflict("Inventory position does not have enough available packages"); + } + WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation(); + allocation.setId(IdWorker.getId()); allocation.setTransferOrderId(transfer.getId()); + allocation.setCustomerId(customerId); allocation.setInventoryPositionId(position.getId()); + allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId()); + allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId()); + allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount()); + allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId()); + allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now); + try { + inventoryAllocations.insert(allocation); + } catch (DuplicateKeyException exception) { + throw conflict("Inventory position is already reserved by another warehouse transfer"); + } + } + transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key); + transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("WAIT_OUTBOUND"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:ALLOCATED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory allocated", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail outbound(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Outbound request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus()) + && key.equals(transfer.getOutboundIdempotencyKey())) { + if (!requestHash.equals(transfer.getOutboundRequestHash())) { + throw conflict("Outbound idempotency key was reused with different data"); + } + return detail(customerId, transferNo); + } + if (!ALLOCATED.equals(transfer.getStatus())) { + throw conflict("Warehouse transfer inventory must be allocated before outbound"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum() + != transfer.getPackageCount()) { + throw conflict("Warehouse transfer inventory allocation is incomplete"); + } + LocalDateTime now = LocalDateTime.now(); + for (WarehouseTransferInventoryAllocation allocation : allocations) { + if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed"); + String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark())); + inventory.allocate(customerId, operatorId, move); + inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark()))); + inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId, + allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo, + "WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark()))); + allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); + allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); + } + transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); + transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:OUTBOUNDED"); order.setUpdatedAt(now); + orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer left source warehouse", now); + return detail(customerId, transferNo); + } + + @Transactional + public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId, + WarehouseTransferRequests.Receipt request) { + WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); + if (transfer == null) throw notFound("Warehouse transfer order not found"); + if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) { + if (RECEIVED.equals(transfer.getStatus())) { + WarehouseTransferReceipt replay = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (replay != null && replay.getRequestHash().equals(hash(request))) return detail(customerId, transferNo); + } + throw conflict("Only outbound warehouse transfer inventory can be received"); + } + List allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId()); + WarehouseTransferInventoryAllocation allocation = allocations.stream() + .filter(value -> request.sourceInventoryPositionId().equals(value.getInventoryPositionId())) + .findFirst().orElseThrow(() -> notFound("Source inventory position is not allocated to this transfer")); + String requestHash = hash(request); + WarehouseTransferReceipt existing = transferReceipts.findSource(customerId, transfer.getId(), + request.sourceInventoryPositionId()); + if (existing != null) { + if (!requestHash.equals(existing.getRequestHash())) { + throw conflict("Transfer source position was already received with different destination data"); + } + return detail(customerId, transferNo); + } + if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt"); + + String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, + new InventoryCommandRequests.StockIn(customerId, null, + request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()), + trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), + request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), + transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + request.destinationPalletId(), request.destinationPalletNo().trim(), + trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), + trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, + "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark()))); + LocalDateTime now = LocalDateTime.now(); + WarehouseTransferReceipt receipt = new WarehouseTransferReceipt(); + receipt.setId(IdWorker.getId()); receipt.setTransferOrderId(transfer.getId()); receipt.setAllocationId(allocation.getId()); + receipt.setCustomerId(customerId); receipt.setSourceInventoryPositionId(allocation.getInventoryPositionId()); + receipt.setDestinationInventoryBalanceId(result.balanceId()); receipt.setDestinationInventoryPositionId(result.positionId()); + receipt.setPackageCount(allocation.getPackageCount()); receipt.setIdempotencyKey(request.idempotencyKey().trim()); + receipt.setRequestHash(requestHash); receipt.setInventoryOperationKey(operationKey); receipt.setCreatedBy(trim(operatorId)); + receipt.setReceivedAt(now); receipt.setCreatedAt(now); transferReceipts.insert(receipt); + allocation.setStatus("RECEIVED"); allocation.setReceivedAt(now); allocation.setUpdatedAt(now); + inventoryAllocations.updateById(allocation); + + boolean complete = allocations.stream().allMatch(value -> value.getId().equals(allocation.getId()) + || "RECEIVED".equals(value.getStatus())); + transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer); + LastlegOrder order = requireTailOrder(customerId, transfer); + String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? "COMPLETED" : "IN_TRANSIT"); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + (complete ? RECEIVED : PARTIALLY_RECEIVED)); + order.setUpdatedAt(now); orders.updateById(order); + insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo, + "Warehouse transfer inventory received at destination", now); + return detail(customerId, transferNo); + } + + private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo, + WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) { + LastlegOrder order = new LastlegOrder(); + order.setId(IdWorker.getId()); order.setOrderNo(orderNoGenerator.next()); order.setCustomerId(customerId); + order.setOrderType(request.needRelabel() ? RETURN_RELABEL_ORDER_TYPE : ORDER_TYPE); + order.setInboundCode(request.inboundCode().trim()); order.setSourceRegion("CUSTOMER"); + order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId)); + order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim()); + order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount()); + order.setVolumeCbm(request.volumeCbm()); + order.setContainerNo(transferNo); + order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0); + order.setEstimatedWarehouseAt(request.estimatedArrivalAt()); + order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW); + order.setCustomerRemark(trim(request.remark())); + order.setSourceEventId("WAREHOUSE_TRANSFER:" + transferNo); order.setSourceLoadingPlanId(ORDER_TYPE); + order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); + orders.insert(order); + return order; + } + + private void insertTransferItem(String customerId, long transferId, WarehouseTransferRequests.Item source, + LocalDateTime now) { + WarehouseTransferItem item = new WarehouseTransferItem(); + item.setId(IdWorker.getId()); item.setTransferOrderId(transferId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setPackageCount(source.packageCount()); item.setShippingMark(trim(source.shippingMark())); item.setCreatedAt(now); + transferItems.insert(item); + } + + private void insertOrderDetail(long orderId, String transferNo, int index, WarehouseTransferRequests.Item source, + WarehouseTransferRequests.Create request, LocalDateTime now) { + LastlegOrderDetail detail = new LastlegOrderDetail(); + detail.setOrderId(orderId); detail.setSourceInboundDetailId(transferNo + ":" + index); + detail.setSkuCode(source.skuCode().trim()); detail.setProductNameZh(source.productNameZh().trim()); + detail.setPackageCount(source.packageCount()); detail.setDestinationWarehouseCode(request.fbaWarehouseCode().trim()); + detail.setFbaTrackingNo(trim(request.trackingNo())); detail.setShipmentNo(request.fbaShipmentNo().trim()); + detail.setShippingMark(trim(source.shippingMark())); orderDetails.insert(detail); + } + + private void createRelabel(String customerId, String operatorId, WarehouseTransferOrder transfer, LastlegOrder order, + WarehouseTransferRequests.Create request, LocalDateTime now) { + long applicationId = IdWorker.getId(); + String relabelNo = "FBR" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + applicationId; + RelabelApplication application = new RelabelApplication(); + application.setId(applicationId); application.setCustomerId(customerId); application.setRelabelNo(relabelNo); + application.setLastlegOrderId(order.getId()); application.setCustomerOrderNo(transfer.getTransferNo()); + application.setOverseasWarehouseId(request.operatingWarehouseId().trim()); application.setLabelDeadline(request.estimatedArrivalAt()); + application.setContactName(StringUtils.hasText(operatorId) ? operatorId.trim() : "SYSTEM"); + application.setContactPhone("N/A"); application.setRemark(trim(request.remark())); + application.setStatus(RelabelApplicationService.PENDING_PROCESSING); application.setItemCount(request.items().size()); + application.setTotalPackageCount(request.packageCount()); application.setNotifiedPackageCount(request.packageCount()); + application.setIdempotencyKey("WAREHOUSE_TRANSFER:" + request.idempotencyKey().trim()); + application.setRequestHash(hash(request)); application.setCreatedBy(trim(operatorId)); + application.setCreatedAt(now); application.setUpdatedAt(now); relabelApplications.insert(application); + + for (WarehouseTransferRequests.Item source : request.items()) { + RelabelItem item = new RelabelItem(); + item.setId(IdWorker.getId()); item.setApplicationId(applicationId); item.setCustomerId(customerId); + item.setSkuCode(source.skuCode().trim()); item.setProductNameZh(source.productNameZh().trim()); + item.setTotalPackageCount(source.packageCount()); item.setNotifiedPackageCount(source.packageCount()); + item.setShipmentNo(request.fbaShipmentNo().trim()); item.setTrackingCode(trim(request.trackingNo())); + item.setNewSkuCode(source.skuCode().trim()); item.setLabelRequirement("WAREHOUSE_TRANSFER_RELABEL"); + item.setCreatedAt(now); relabelItems.insert(item); + } + RelabelStatusHistory history = new RelabelStatusHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(customerId); history.setApplicationId(applicationId); + history.setToStatus(RelabelApplicationService.PENDING_PROCESSING); history.setOperatorId(trim(operatorId)); + history.setRemark("Created from warehouse transfer " + transfer.getTransferNo()); history.setCreatedAt(now); + relabelHistories.insert(history); + transfer.setRelabelApplicationId(applicationId); transfer.setRelabelNo(relabelNo); transfer.setUpdatedAt(now); + transfers.updateById(transfer); + order.setRelabelOrderNo(relabelNo); order.setRelabelStatus(RelabelApplicationService.PENDING_PROCESSING); + order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + PENDING_REVIEW + ";RELABEL:" + RelabelApplicationService.PENDING_PROCESSING); + order.setUpdatedAt(now); orders.updateById(order); + } + + private void insertOrderHistory(long orderId, String fromStatus, String toStatus, String transferNo, + String remark, LocalDateTime now) { + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); + history.setOrderId(orderId); history.setFromStatus(fromStatus); history.setToStatus(toStatus); + history.setEventId("WAREHOUSE_TRANSFER:" + transferNo); history.setRemark(remark); + history.setCreatedAt(now); orderHistories.insert(history); + } + + private LastlegOrder requireTailOrder(String customerId, WarehouseTransferOrder transfer) { + LastlegOrder order = orders.selectById(transfer.getLastlegOrderId()); + if (order == null || !customerId.equals(order.getCustomerId())) { + throw conflict("Linked tail-leg order is missing"); + } + return order; + } + + private String hash(Object value) { + try { + byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static long packages(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java new file mode 100644 index 000000000..4b9ecc170 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/TruckingMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.trucking; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.trucking.mapper") +public class TruckingMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java new file mode 100644 index 000000000..2c30b5907 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders") +public class MerchantUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody UsTruckOrderRequests.Create request) { + return ApiResponse.success(service.create(customer(user), operator(user), request)); + } + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + return ApiResponse.success(service.page(customer(user), query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo) { + return ApiResponse.success(service.detail(customer(user), orderNo)); + } + + @PatchMapping("/{orderNo}") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { + return ApiResponse.success(service.update(customer(user), orderNo, request)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + } + return String.valueOf(user.tenantId()); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java new file mode 100644 index 000000000..3dba2f252 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.trucking.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.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/us-truck-orders") +public class PlatformUsTruckOrderController { + @Resource private UsTruckOrderService service; + + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid UsTruckOrderRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(service.page(null, query)); + } + + @GetMapping("/{orderNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo) { + requirePlatform(user); + return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); + } + + @PatchMapping("/{orderNo}/status") + public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { + requirePermission(user); + return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/receive-pallets") + public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { + requirePermission(user); + return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); + } + } + + private void requirePermission(AuthenticatedUser user) { + requirePlatform(user); + if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + } + } + + private String requiredCustomer(String customerId) { + if (customerId == null || customerId.isBlank() || "0".equals(customerId.trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Target customerId is required"); + } + return customerId.trim(); + } + + private String operator(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java new file mode 100644 index 000000000..120364ad3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.trucking.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class UsTruckOrderRequests { + private UsTruckOrderRequests() {} + + public record Create( + @Size(max = 32) String transportType, + @NotBlank @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @NotBlank @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @NotNull @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, + @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @NotBlank @Size(max = 128) String contactName, + @NotBlank @Size(max = 128) String contactInfo, + @NotNull LocalDate requestedDate, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey) { + /** Backward-compatible constructor for clients still sending legacy transport fields. */ + public Create(String transportType, String origin, String originAddress, String destination, + String destinationAddress, String containerType, Integer packageCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String remark, String idempotencyKey) { + this(transportType, origin, originAddress, destination, destinationAddress, containerType, + packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount, + totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo, + requestedDate, remark, idempotencyKey); + } + } + + public record Query( + @Min(1) long page, @Min(1) @Max(200) long size, + String orderNo, String routeKeyword, String status, String transportType, + String customerId, LocalDate requestedFrom, LocalDate requestedTo) {} + + public record Update( + @NotNull @Min(0) Long version, + @Size(max = 32) String transportType, + @Size(max = 255) String origin, + @Size(max = 500) String originAddress, + @Size(max = 255) String destination, + @Size(max = 500) String destinationAddress, + @Size(max = 32) String containerType, + @Min(1) Integer palletCount, + @Min(0) Integer packageCount, + @DecimalMin("0.00") BigDecimal totalWeightKg, + @DecimalMin("0.00") BigDecimal totalVolumeCbm, + @DecimalMin("0.00") BigDecimal budgetAmount, + @Size(min = 3, max = 3) String currency, + @Size(max = 128) String contactName, + @Size(max = 128) String contactInfo, + LocalDate requestedDate, + @Size(max = 1000) String remark) {} + + public record ChangeStatus( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 32) String status, + @Size(max = 1000) String remark) {} + + public record ReceivePallets( + @NotNull @Min(0) Long version, + @NotNull @Min(1) Integer actualPalletCount, + @Min(0) Integer packageCount, + @Size(max = 500) List<@Min(0) Integer> palletPackageCounts, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { + public ReceivePallets(Long version, Integer actualPalletCount, Integer packageCount, + String idempotencyKey, String remark) { + this(version, actualPalletCount, packageCount, List.of(), idempotencyKey, remark); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java new file mode 100644 index 000000000..6169954b8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.trucking.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public final class UsTruckOrderResponses { + private UsTruckOrderResponses() {} + + public record Summary( + Long id, String customerId, String orderNo, String transportType, + String origin, String destination, String containerType, int palletCount, int packageCount, + int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount, + BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, + String currency, String contactName, String contactInfo, LocalDate requestedDate, + String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} + + public record History( + String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + + public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status, + Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {} + + public record Detail( + Summary summary, String originAddress, String destinationAddress, String remark, + String createdBy, List pallets, List histories) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java new file mode 100644 index 000000000..264ab0905 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.trucking.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_us_truck_order") +public class UsTruckOrder { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private String orderNo; + private String transportType; + private String origin; + private String originAddress; + private String destination; + private String destinationAddress; + private String containerType; + private Integer palletCount; + private Integer packageCount; + private Integer receivedPalletCount; + private Integer allocatedPalletCount; + private Integer shippedPalletCount; + private BigDecimal totalWeightKg; + private BigDecimal totalVolumeCbm; + private BigDecimal budgetAmount; + private String currency; + private String contactName; + private String contactInfo; + private LocalDate requestedDate; + private String status; + private String remark; + private String idempotencyKey; + private String requestHash; + private String receiptIdempotencyKey; + private String receiptRequestHash; + private String createdBy; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java new file mode 100644 index 000000000..593e7047b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.trucking.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_us_truck_order_pallet") +public class UsTruckOrderPallet { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String palletNo; + private Integer sequenceNo; + private String source; + private String status; + private Long warehousePalletId; + private Integer packageCount; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java new file mode 100644 index 000000000..be4fee492 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.trucking.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_us_truck_order_status_history") +public class UsTruckOrderStatusHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long orderId; + private String customerId; + private String fromStatus; + private String toStatus; + private String operatorId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java new file mode 100644 index 000000000..f6c43085d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") + UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrder findKey(@Param("customerId") String customerId, @Param("key") String key); + + @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1 FOR UPDATE") + UsTruckOrder lockNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java new file mode 100644 index 000000000..3eecc0897 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface UsTruckOrderPalletMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_pallet WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY sequence_no") + List listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java new file mode 100644 index 000000000..8875ec5b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface UsTruckOrderStatusHistoryMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_status_history WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY created_at,id") + List listOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); +} 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 new file mode 100644 index 000000000..4c6453f2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -0,0 +1,278 @@ +package com.baiyue.lastleg.trucking.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +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.UsTruckOrderPalletMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +@Service +public class UsTruckOrderService { + public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + public static final String PENDING_REVIEW = "PENDING_REVIEW"; + /** Kept for source compatibility; new orders use PENDING_REVIEW. */ + @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; + public static final String WAITING_INBOUND = "WAITING_INBOUND"; + public static final String RECEIVING = "RECEIVING"; + public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; + public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW"; + public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED"; + public static final String ALLOCATED = "ALLOCATED"; + public static final String PENDING_DISPATCH = "PENDING_DISPATCH"; + public static final String DISPATCHED = "DISPATCHED"; + public static final String IN_TRANSIT = "IN_TRANSIT"; + public static final String COMPLETED = "COMPLETED"; + public static final String REJECTED = "REJECTED"; + public static final String CANCELLED = "CANCELLED"; + private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, + PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); + private static final java.util.Map> TRANSITIONS = java.util.Map.ofEntries( + java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)), + java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)), + java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)), + java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)), + java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)), + java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)), + java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)), + java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)), + java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)), + java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED))); + private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); + + @Resource private UsTruckOrderMapper orders; + @Resource private UsTruckOrderPalletMapper pallets; + @Resource private UsTruckOrderStatusHistoryMapper histories; + @Resource private ObjectMapper objectMapper; + + @Transactional + public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { + String key = request.idempotencyKey().trim(); + String hash = hash(request); + UsTruckOrder replay = orders.findKey(customerId, key); + if (replay != null) return replay(customerId, replay, hash); + if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType()); + LocalDateTime now = LocalDateTime.now(); + UsTruckOrder order = new UsTruckOrder(); + order.setId(IdWorker.getId()); + order.setCustomerId(customerId); + order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId()); + order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); + order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); + order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); + order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); + order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); + order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); + order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); + order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW); + order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); + order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); + try { orders.insert(order); } + catch (DuplicateKeyException e) { + UsTruckOrder concurrent = orders.findKey(customerId, key); + if (concurrent != null) return replay(customerId, concurrent, hash); + throw e; + } + history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + return detail(customerId, order.getOrderNo()); + } + + public PageResult page(String customerId, UsTruckOrderRequests.Query query) { + validateQuery(query); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) + .or().like(UsTruckOrder::getDestination, query.routeKeyword())) + .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, + StringUtils.hasText(query.status()) ? norm(query.status()) : null) + .eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType, + StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null) + .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) + .ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom()) + .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) + .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); + Page result = orders.selectPage(Page.of(query.page(), query.size()), w); + return new PageResult<>(result.getRecords().stream().map(this::summary).toList(), result.getCurrent(), + result.getSize(), result.getTotal(), result.getPages()); + } + + public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { + UsTruckOrder order = orders.findNo(customerId, orderNo); + if (order == null) throw notFound(); + return detail(order, histories.listOrder(customerId, order.getId())); + } + + @Transactional + public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) { + UsTruckOrder order = lock(customerId, orderNo); + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); + if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } + setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); + setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); + setText(request.containerType(), v -> order.setContainerType(norm(v))); + if (request.palletCount() != null) order.setPalletCount(request.palletCount()); + if (request.packageCount() != null) order.setPackageCount(request.packageCount()); + if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); + if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); + setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); + if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); + touch(order); orders.updateById(order); return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ChangeStatus request) { + UsTruckOrder order = lock(customerId, orderNo); + 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 truck order status"); + String from = order.getStatus(); + if (!next.equals(from)) { + if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { + throw conflict("Unsupported truck order transition: " + from + " -> " + next); + } + order.setStatus(next); + if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order); + touch(order); orders.updateById(order); + history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); + } + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReceivePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + String key = request.idempotencyKey().trim(); + String requestHash = hash(request); + if (key.equals(order.getReceiptIdempotencyKey())) { + if (!requestHash.equals(order.getReceiptRequestHash())) { + throw conflict("Receipt idempotency key was reused with different pallet data"); + } + return detail(customerId, orderNo); + } + if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) { + throw conflict("Only waiting or receiving orders can receive pallets"); + } + + createPlannedPallets(order); + List current = pallets.listOrder(customerId, order.getId()); + int planned = value(order.getPalletCount()); + int actual = request.actualPalletCount(); + List packageCounts = request.palletPackageCounts() == null ? List.of() + : request.palletPackageCounts(); + if (!packageCounts.isEmpty() && packageCounts.size() != actual) { + throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet"); + } + if (!packageCounts.isEmpty() && request.packageCount() != null + && packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) { + throw badRequest("Per-pallet package counts must equal the received package total"); + } + LocalDateTime now = LocalDateTime.now(); + for (int sequence = 1; sequence <= actual; sequence++) { + UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1) + : newPallet(order, sequence, "PDA_OVERAGE", now); + pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED"); + if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1)); + else if (actual == 1) pallet.setPackageCount(request.packageCount()); + pallet.setReceivedAt(now); + pallet.setUpdatedAt(now); + if (pallet.getId() == null) { + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } else { + pallets.updateById(pallet); + } + } + order.setReceivedPalletCount(actual); + order.setReceiptIdempotencyKey(key); + order.setReceiptRequestHash(requestHash); + String from = order.getStatus(); + String next = actual > planned ? PALLET_OVERAGE_REVIEW + : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; + order.setStatus(next); + touch(order); + orders.updateById(order); + history(order, from, next, operatorId, request.remark(), now); + return detail(customerId, orderNo); + } + + 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) { + List palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() + .map(this::pallet) + .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()); + } + 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 void createPlannedPallets(UsTruckOrder order) { + int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); + LocalDateTime now = LocalDateTime.now(); + for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) { + UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now); + pallet.setId(IdWorker.getId()); + pallets.insert(pallet); + } + } + private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) { + UsTruckOrderPallet pallet = new UsTruckOrderPallet(); + pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId()); + pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence)); + pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED"); + pallet.setCreatedAt(now); pallet.setUpdatedAt(now); + return pallet; + } + private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); } + private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); } + private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } } + private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); } + private static void setText(String v, Consumer c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + private static void setPresent(String v, Consumer c) { if (v != null) c.accept(trim(v)); } + private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); } + private static String norm(String v) { return v.trim().toUpperCase(); } + private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; } + private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; } + private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); } + private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); } +} diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml new file mode 100644 index 000000000..250cdcb8d --- /dev/null +++ b/lastleg-service/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: ${LASTLEG_PORT:9303} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/lastleg.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: lastleg-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config.namespace: ${NACOS_NAMESPACE:public} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${LASTLEG_DB_USERNAME:root} + password: ${LASTLEG_DB_PASSWORD:123456} +management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql new file mode 100644 index 000000000..6de76faf9 --- /dev/null +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -0,0 +1,1139 @@ +CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY(event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (customer_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花 ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + root_inbound_id VARCHAR(128) COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) COMMENT '来源订单 ID', + source_order_no VARCHAR(128) COMMENT '来源订单号', + root_order_id VARCHAR(128) COMMENT '根订单 ID', + customer_name VARCHAR(255) COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) COMMENT '海外仓 ID', + overseas_warehouse_name VARCHAR(255) COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) COMMENT '转入目的仓 ID', + transfer_warehouse_code VARCHAR(128) COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) COMMENT 'FBA 仓库编码', + container_no VARCHAR(64) COMMENT '集装箱号', + container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + product_quantity INT COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) COMMENT '入库单号', + outbound_order_no VARCHAR(128) COMMENT '出库单号', + delivery_order_no VARCHAR(128) COMMENT '派送单号', + relabel_order_no VARCHAR(128) COMMENT '换标单号', + relabel_status VARCHAR(32) COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + sub_order_status VARCHAR(255) NULL COMMENT '子单据状态汇总', + private_remark VARCHAR(1000) COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', + source_handover_version BIGINT COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT 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_order_source (customer_id,source_region,source_inbound_id), + KEY idx_ll_order_group(customer_id,order_no), + KEY idx_ll_order_container(customer_id,container_no), + KEY idx_ll_order_list(customer_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + sku_code VARCHAR(128) COMMENT 'SKU 编码', + product_name_zh VARCHAR(255) COMMENT '中文品名', + product_name_en VARCHAR(255) COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT COMMENT '数量', + pallet_count INT COMMENT '托数', + gross_weight_kg DECIMAL(18,4) COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) COMMENT '货件编号', + shipping_mark VARCHAR(255) COMMENT '唛头', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_detail_source(order_id,source_inbound_detail_id), + KEY idx_ll_detail_order(order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + from_status VARCHAR(32) COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) COMMENT '触发事件 ID', + remark VARCHAR(500) COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY(id), + KEY idx_ll_loading_history_order(order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_event_conflict ( + id BIGINT NOT NULL COMMENT '冲突记录主键', event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_conflict_event(event_id,customer_id) +) ENGINE=InnoDB COMMENT='事件冲突记录'; + +CREATE TABLE IF NOT EXISTS ll_event_inbox ( + id BIGINT NOT NULL COMMENT '事件接收记录主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', + customer_id BIGINT NOT NULL COMMENT '租户 ID', event_type VARCHAR(128) NOT NULL COMMENT '事件类型', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '消费状态', error_message VARCHAR(512) NULL COMMENT '错误信息', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbox_event(event_id), KEY idx_ll_inbox_source(customer_id,aggregate_id) +) ENGINE=InnoDB COMMENT='通用事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order ( + id BIGINT NOT NULL COMMENT '入仓单主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', source_order_no VARCHAR(128) NULL COMMENT '来源订单号', root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + inbound_code VARCHAR(128) NULL COMMENT '入仓码', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', + source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', handover_version BIGINT NOT NULL COMMENT '交接版本', payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', + status VARCHAR(32) NOT NULL COMMENT '入仓单状态', exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_source(customer_id,source_region,source_inbound_id), UNIQUE KEY uk_ll_order_event(source_event_id,source_inbound_id) +) ENGINE=InnoDB COMMENT='尾程入仓单快照'; + +CREATE TABLE IF NOT EXISTS ll_inbound_order_item ( + id BIGINT NOT NULL COMMENT '入仓明细主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '入仓单 ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', package_count INT NOT NULL COMMENT '箱数', product_quantity INT NULL COMMENT '数量', pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', shipment_no VARCHAR(128) NULL COMMENT '货件编号', shipping_mark VARCHAR(255) NULL COMMENT '唛头', + sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', product_name_en VARCHAR(255) NULL COMMENT '英文品名', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_item_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='尾程入仓商品明细快照'; + +CREATE TABLE IF NOT EXISTS ll_order_status_history ( + id BIGINT NOT NULL COMMENT '订单状态历史主键', customer_id BIGINT NOT NULL COMMENT '租户 ID', order_id BIGINT NOT NULL COMMENT '订单 ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', reason VARCHAR(255) NULL COMMENT '变更原因', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='尾程订单状态历史'; + +-- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 +ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; +ALTER TABLE ll_order COMMENT='尾程订单主表'; +ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; +ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; +ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; +ALTER TABLE ll_event_inbox COMMENT='通用事件接收幂等箱'; +ALTER TABLE ll_inbound_order COMMENT='尾程入仓单快照'; +ALTER TABLE ll_inbound_order_item COMMENT='尾程入仓商品明细快照'; +ALTER TABLE ll_order_status_history COMMENT='尾程订单状态历史'; + +ALTER TABLE ll_event_conflict + MODIFY COLUMN id BIGINT NOT NULL COMMENT '冲突记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '冲突事件 ID', + MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT '冲突类型', + MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT '冲突说明', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_event_inbox + MODIFY COLUMN id BIGINT NOT NULL COMMENT '事件接收记录主键', MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一 ID', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', + MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT '事件类型', MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合 ID', MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT '聚合版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '消费状态', MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT '错误信息', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓单主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域', MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '来源入仓分片 ID', MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '入仓码', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '装柜计划 ID', MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT '装柜计划版本', MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT '交接版本', MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '负载摘要', MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '入仓单状态', MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT 'NONE' COMMENT '异常标记', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'; +ALTER TABLE ll_inbound_order_item + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入仓明细主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '入仓单ID', MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', MODIFY COLUMN package_count INT NOT NULL COMMENT '箱数', MODIFY COLUMN product_quantity INT NULL COMMENT '数量', MODIFY COLUMN pallet_count INT NULL COMMENT '托数', MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克)', MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT '净重(千克)', MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT '体积重量(千克)', MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT '体积(立方米)', MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT 'FBA跟踪号', MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT '货件编号', MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头', MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU编码', MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'; +ALTER TABLE ll_order_status_history + MODIFY COLUMN id BIGINT NOT NULL COMMENT '订单状态历史主键', MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT '租户 ID', MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '订单 ID', MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', MODIFY COLUMN reason VARCHAR(255) NULL COMMENT '变更原因', MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间'; + +ALTER TABLE ll_loading_event_inbox + MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + MODIFY COLUMN event_version INT NOT NULL COMMENT '事件契约版本', + MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + MODIFY COLUMN payload_json JSON NOT NULL COMMENT '事件冻结负载 JSON', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT '处理完成时间', + MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误'; + +ALTER TABLE ll_order + MODIFY COLUMN id BIGINT NOT NULL COMMENT '雪花 ID', + MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单 ID', + MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT '来源订单 ID', + MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT '根订单 ID', + MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT '货主名称', + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓 ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA 仓库编码', + MODIFY COLUMN container_no VARCHAR(64) NULL COMMENT '集装箱号', + MODIFY COLUMN container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN product_quantity INT NULL COMMENT '货物数量', + MODIFY COLUMN volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + MODIFY COLUMN actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + MODIFY COLUMN estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + MODIFY COLUMN delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + MODIFY COLUMN relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + MODIFY COLUMN relabel_status VARCHAR(32) NULL COMMENT '换标状态', + MODIFY COLUMN pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + MODIFY COLUMN private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + MODIFY COLUMN customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', + MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', + MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Existing installations: convert the row identity to an assigned Snowflake ID and +-- make order_no a container/loading-plan grouping number rather than a row-unique key. +SET @ll_add_transfer_warehouse_id = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_id' +); +PREPARE ll_add_transfer_warehouse_id_stmt FROM @ll_add_transfer_warehouse_id; +EXECUTE ll_add_transfer_warehouse_id_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_id_stmt; + +SET @ll_add_transfer_warehouse_code = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_code' +); +PREPARE ll_add_transfer_warehouse_code_stmt FROM @ll_add_transfer_warehouse_code; +EXECUTE ll_add_transfer_warehouse_code_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_code_stmt; + +SET @ll_add_transfer_warehouse_name = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code', + 'SELECT 1') + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND column_name = 'transfer_warehouse_name' +); +PREPARE ll_add_transfer_warehouse_name_stmt FROM @ll_add_transfer_warehouse_name; +EXECUTE ll_add_transfer_warehouse_name_stmt; +DEALLOCATE PREPARE ll_add_transfer_warehouse_name_stmt; + +ALTER TABLE ll_order + MODIFY COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + MODIFY COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + MODIFY COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称'; + +SET @ll_drop_order_no_unique = ( + SELECT IF(COUNT(*) > 0, + 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'uk_ll_order_no' AND non_unique = 0 +); +PREPARE ll_drop_order_no_unique_stmt FROM @ll_drop_order_no_unique; +EXECUTE ll_drop_order_no_unique_stmt; +DEALLOCATE PREPARE ll_drop_order_no_unique_stmt; + +SET @ll_add_order_group_index = ( + SELECT IF(COUNT(*) = 0, + 'CREATE INDEX idx_ll_order_group ON ll_order(customer_id,order_no)', + 'SELECT 1') + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'll_order' + AND index_name = 'idx_ll_order_group' +); +PREPARE ll_add_order_group_index_stmt FROM @ll_add_order_group_index; +EXECUTE ll_add_order_group_index_stmt; +DEALLOCATE PREPARE ll_add_order_group_index_stmt; + +ALTER TABLE ll_order_detail + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细 ID', + MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT 'SKU 编码', + MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT '英文品名', + MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + MODIFY COLUMN product_quantity INT NULL COMMENT '数量', + MODIFY COLUMN pallet_count INT NULL COMMENT '托数', + MODIFY COLUMN gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + MODIFY COLUMN net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓 ID', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + MODIFY COLUMN fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + MODIFY COLUMN shipment_no VARCHAR(255) NULL COMMENT '货件编号', + MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT '唛头'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id, order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id, pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- 修复既有入库计划表中的英文注释。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; +ALTER TABLE ll_inbound_plan + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', + MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '集装箱总体积(立方米)', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE ll_inbound_plan_line COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +ALTER TABLE ll_inbound_plan_line + MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单ID', + MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量'; + +ALTER TABLE ll_inbound_plan_pallet COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + MODIFY COLUMN sequence_no INT NOT NULL COMMENT '计划内托盘序号'; + +ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +ALTER TABLE ll_inbound_plan_pallet_allocation + MODIFY COLUMN id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT '入库计划ID', + MODIFY COLUMN line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; + +-- Additive inbound picking schema. Guarded statements preserve existing rows on restart. +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT''')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', + pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容JSON', result_json LONGTEXT NOT NULL COMMENT '处理结果JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (customer_id, idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='入仓PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + merge_group_no VARCHAR(64) NOT NULL COMMENT '合托分组号', source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配记录ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配记录ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否已报废', status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (customer_id, task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='入仓托盘合托记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (customer_id, appointment_no), + KEY idx_ll_fba_appointment_list (customer_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +ALTER TABLE ll_loading_order_status_history + MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '尾程订单 ID', + MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT '变更前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + MODIFY COLUMN event_id VARCHAR(128) NULL COMMENT '触发事件 ID', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '状态变更备注', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间'; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT NULL COMMENT '仓库库位编码', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (customer_id,pallet_id), + KEY idx_ll_inventory_position_location (customer_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', sku_code VARCHAR(128) NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NULL COMMENT '箱唛', carton_packages BIGINT NULL COMMENT '箱内箱数', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark 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_inventory_ledger_idempotency (customer_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (customer_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (customer_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (customer_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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_inventory_operation_key (customer_id,idempotency_key), + KEY idx_ll_inventory_operation_status (customer_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', + batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', + inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id), + KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', + driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', + carrier_name VARCHAR(255) NULL COMMENT '承运商名称', + route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', + fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已出库箱数', + partial_completion_reason VARCHAR(1000) NULL COMMENT '部分完成原因', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_dispatch_order_no (customer_id,dispatch_no), + KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), + KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', + order_no VARCHAR(64) NULL COMMENT '订单号', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', + inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (customer_id,order_id), + KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_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_dispatch_active_pallet (customer_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE dispatch_schema_stmt FROM @ddl; EXECUTE dispatch_schema_stmt; DEALLOCATE PREPARE dispatch_schema_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_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_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (customer_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (customer_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Transfer a dispatch to delivery: one outbound header, many pallet rows, one delivery header. +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (customer_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), + KEY idx_ll_outbound_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (customer_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (customer_id,outbound_order_id), + KEY idx_ll_delivery_list (customer_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- Customer warehouse transfer: generated tail order plus selected historical order/inbound bindings. +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + allocation_idempotency_key VARCHAR(255) NULL COMMENT '库存分配幂等键', + allocation_request_hash CHAR(64) NULL COMMENT '库存分配请求哈希', + outbound_idempotency_key VARCHAR(255) NULL COMMENT '出库幂等键', + outbound_request_hash CHAR(64) NULL COMMENT '出库请求哈希', + outbound_at DATETIME(3) NULL COMMENT '出库时间', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_wh_transfer_no (customer_id,transfer_no), + UNIQUE KEY uk_ll_wh_transfer_key (customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id), + KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id), + KEY idx_ll_wh_transfer_source_list (customer_id,transfer_order_id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', + active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', + shipped_at DATETIME(3) NULL COMMENT '出库时间', + received_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_wh_transfer_allocation_position(transfer_order_id,inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key), + KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', + transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', + allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', + destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id), + UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key), + UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key), + KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +-- Customer relabel application: header, item snapshots, and immutable process history. +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', + notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_relabel_application_no (customer_id,relabel_no), + UNIQUE KEY uk_ll_relabel_application_idempotency (customer_id,idempotency_key), + UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id), + KEY idx_ll_relabel_application_list (customer_id,status,created_at), + KEY idx_ll_relabel_application_customer (customer_id,customer_order_no), + KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', + product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', + product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + total_package_count INT NOT NULL COMMENT '总箱数', + notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', + tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', + new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', + relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', + relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_item_application (customer_id,application_id), + KEY idx_ll_relabel_item_sku (customer_id,sku_code) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + application_id BIGINT NOT NULL COMMENT '申请单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', + source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', + inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', + idempotency_key VARCHAR(255) NOT 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no), + UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态', outbound_order_no VARCHAR(128) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), + KEY idx_ll_dropship_carton_status(customer_id,status), KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; + +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT 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_dropship_sku_inventory(profile_id,sku_code), + KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; + +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark 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_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' 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 '审核备注', + 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), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', + overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + quantity BIGINT NOT NULL COMMENT '数量', outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', + tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', + remark VARCHAR(1000) 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_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), + KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) +) ENGINE=InnoDB COMMENT='海外采购出库记录'; + +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', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + 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='海外采购审核历史'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', + origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', + destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + 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 '预算金额', + currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + 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', + 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) COMMENT '更新时间', + 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='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', + order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', + sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', + package_count INT NULL COMMENT '托盘箱数', + received_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_us_truck_order_pallet_no(customer_id,order_id,pallet_no), + UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + 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_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; 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 new file mode 100644 index 000000000..a00c65ad8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class LastlegOrderControllerTest { + private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderController controller = controller(); + private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( + 1, 20, null, null, null, null, null, null, null, null, null, null); + + @Test + void customerCannotOverrideItsTenantScope() { + controller.list(user(100L, "MERCHANT"), "200", query); + verify(service).page(eq("100"), eq(query)); + } + + @Test + void platformOperatorMustExplicitlyChooseCustomerTenant() { + assertThrows(ResponseStatusException.class, + () -> controller.list(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformOperatorCanQueryExplicitCustomerTenant() { + controller.list(user(0L, "PLATFORM"), "200", query); + verify(service).page(eq("200"), eq(query)); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private LastlegOrderController controller() { + LastlegOrderController controller = new LastlegOrderController(); + ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.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/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java new file mode 100644 index 000000000..4dee1fd33 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DeliverySchemaContractTest { + @Test + void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception { + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(schema.contains("create table if not exists ll_outbound_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); + assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); + assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id)")); + assertTrue(schema.contains("destination_warehouse_code varchar(128) null")); + assertTrue(schema.contains("order_refs varchar(2000) null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("create table if not exists ll_delivery_order")); + assertTrue(schema.contains("status varchar(32) not null default 'wait_delivery'")); + assertTrue(schema.contains("unique key uk_ll_delivery_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("unique key uk_ll_delivery_outbound (customer_id,outbound_order_id)")); + } + + @Test + void deliveryPersistenceEntitiesUseSnowflakeIds() throws Exception { + for (Class type : new Class[] { OutboundOrder.class, OutboundPalletItem.class, DeliveryOrder.class }) { + TableId id = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(id, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, id.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationAddsTransferTablesAndIsRerunnable() throws Exception { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + + assertTrue(migration.contains("2026-08-17 / 014")); + assertTrue(migration.contains("create table if not exists ll_outbound_order")); + assertTrue(migration.contains("create table if not exists ll_outbound_pallet_item")); + assertTrue(migration.contains("create table if not exists ll_delivery_order")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java new file mode 100644 index 000000000..d02e94c48 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -0,0 +1,297 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DeliveryTransferServiceTest { + @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchItemMapper dispatchItems; + @Mock OutboundOrderMapper outboundOrders; + @Mock OutboundPalletItemMapper outboundPallets; + @Mock DeliveryOrderMapper deliveryOrders; + @Mock InboundPlanPalletAllocationMapper planAllocations; + @Mock InboundPlanLineMapper planLines; + @Mock LastlegOrderMapper tailOrders; + @Mock LastlegOrderStatusHistoryMapper histories; + + private DeliveryTransferService service; + + @BeforeEach + void setUp() { + service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); + ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); + ReflectionTestUtils.setField(service, "planAllocations", planAllocations); + ReflectionTestUtils.setField(service, "planLines", planLines); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); + ReflectionTestUtils.setField(service, "histories", histories); + } + + @Test + void firstTransferCreatesOneOutboundOneDeliveryAndAllPalletSnapshots() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12), + item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8) + )); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", "ready")); + + ArgumentCaptor outbound = ArgumentCaptor.forClass(OutboundOrder.class); + ArgumentCaptor delivery = ArgumentCaptor.forClass(DeliveryOrder.class); + ArgumentCaptor pallets = ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(outboundOrders).insert(outbound.capture()); + verify(deliveryOrders).insert(delivery.capture()); + verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture()); + + assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus()); + assertEquals(2, outbound.getValue().getPalletCount()); + assertEquals(20, outbound.getValue().getPackageCount()); + assertEquals("transfer-101", outbound.getValue().getIdempotencyKey()); + assertTrue(outbound.getValue().getOutboundNo().startsWith("OUT")); + assertEquals(outbound.getValue().getId(), delivery.getValue().getOutboundOrderId()); + assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus()); + assertTrue(delivery.getValue().getDeliveryNo().startsWith("DEL")); + assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getDestinationWarehouseCode).toList()); + assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream() + .map(OutboundPalletItem::getInboundCode).toList()); + assertEquals(20, result.packageCount()); + assertEquals(2, result.palletCount()); + assertTrue(!result.replayed()); + } + + @Test + void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer(" transfer-101 ", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void idempotencyKeyCannotBeReusedForAnotherDispatch() { + DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A"); + OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-102")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 102L)).thenReturn(dispatch); + when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-102", "U-1", new DeliveryRequests.Transfer("shared-key", null))); + + assertEquals(409, exception.getStatusCode().value()); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void concurrentDuplicateInsertReturnsWinningDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, winner, "DEL-401"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of( + item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12))); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate")); + when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + + DeliveryResponses.TransferResult result = service.transfer("TENANT-A", "DSP-101", "U-1", + new DeliveryRequests.Transfer("transfer-101", null)); + + assertTrue(result.replayed()); + assertEquals("OUT-301", result.outboundNo()); + assertEquals("DEL-401", result.deliveryNo()); + verify(outboundPallets, never()).insert((OutboundPalletItem) any()); + verify(deliveryOrders, never()).insert((DeliveryOrder) any()); + } + + @Test + void directDispatchOrderReferenceIsBoundToCreatedDocuments() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + source.setOrderId(901L); + LastlegOrder tailOrder = new LastlegOrder(); + tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source)); + when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder)); + + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("direct-order", null)); + + assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus()); + assertNotNull(tailOrder.getOutboundOrderNo()); + assertNotNull(tailOrder.getDeliveryOrderNo()); + verify(tailOrders).updateById(tailOrder); + verify(histories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void transferRejectsAPlannedPalletThatHasNotBeenReceived() { + DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A"); + DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12); + planned.setSourceStage("PLAN"); + when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch); + when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch); + when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.transfer("TENANT-A", "DSP-101", "U-1", new DeliveryRequests.Transfer("transfer-101", null))); + + assertEquals(409, exception.getStatusCode().value()); + assertTrue(exception.getReason().contains("received")); + verify(outboundOrders, never()).insert((OutboundOrder) any()); + } + + @Test + void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() { + OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); + DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); + OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); + OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); + when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); + when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); + when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") Page page = invocation.getArgument(0); + page.setRecords(List.of(first, second)); + page.setTotal(2); + return page; + }); + + DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", + new DeliveryRequests.PalletQuery(1, 20, null)); + + PageResult pallets = detail.pallets(); + assertEquals(2, pallets.total()); + assertEquals(2, pallets.records().size()); + assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode()); + assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName()); + assertEquals(12, pallets.records().get(0).packageCount()); + assertEquals("IN-A", pallets.records().get(0).inboundCode()); + assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode()); + assertEquals(8, pallets.records().get(1).packageCount()); + } + + @Test + void detailLookupCannotCrossTenantBoundary() { + when(outboundOrders.findByNo("TENANT-B", "OUT-301")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.outboundDetail("TENANT-B", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null))); + + assertEquals(404, exception.getStatusCode().value()); + verify(outboundOrders).findByNo("TENANT-B", "OUT-301"); + verify(deliveryOrders, never()).lockByOutbound(any(), any()); + verify(outboundPallets, never()).selectPage(any(), any()); + } + + private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { + DispatchOrder value = new DispatchOrder(); + value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); + value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART"); + return value; + } + + private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + DispatchItem value = new DispatchItem(); + value.setId(id); value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + value.setSourceStage("RECEIVED"); + return value; + } + + private static OutboundOrder outbound(Long id, Long dispatchId, String dispatchNo, String outboundNo, + String idempotencyKey) { + OutboundOrder value = new OutboundOrder(); + value.setId(id); value.setCustomerId("TENANT-A"); value.setDispatchOrderId(dispatchId); + value.setDispatchNo(dispatchNo); value.setOutboundNo(outboundNo); value.setStatus("WAIT_OUTBOUND"); + value.setOverseasWarehouseId("WH-US-1"); value.setPalletCount(2); value.setPackageCount(20); + value.setIdempotencyKey(idempotencyKey); value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static DeliveryOrder delivery(Long id, OutboundOrder outbound, String deliveryNo) { + DeliveryOrder value = new DeliveryOrder(); + value.setId(id); value.setCustomerId(outbound.getCustomerId()); value.setDeliveryNo(deliveryNo); + value.setOutboundOrderId(outbound.getId()); value.setOutboundNo(outbound.getOutboundNo()); + value.setDispatchOrderId(outbound.getDispatchOrderId()); value.setDispatchNo(outbound.getDispatchNo()); + value.setOverseasWarehouseId(outbound.getOverseasWarehouseId()); value.setStatus("WAIT_DELIVERY"); + value.setPalletCount(outbound.getPalletCount()); value.setPackageCount(outbound.getPackageCount()); + value.setCreatedAt(LocalDateTime.now()); + return value; + } + + private static OutboundPalletItem pallet(Long warehousePalletId, String palletNo, String destinationCode, + String destinationName, String inboundCode, int packages) { + OutboundPalletItem value = new OutboundPalletItem(); + value.setId(warehousePalletId + 1000); value.setCustomerId("TENANT-A"); value.setOutboundOrderId(301L); + value.setWarehousePalletId(warehousePalletId); value.setPalletNo(palletNo); + value.setDestinationWarehouseId("DEST-" + destinationCode); + value.setDestinationWarehouseCode(destinationCode); value.setDestinationWarehouseName(destinationName); + value.setInboundCode(inboundCode); value.setOrderRefs("LMO-1"); value.setPackageCount(packages); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java new file mode 100644 index 000000000..58883d2ea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java @@ -0,0 +1,164 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.mybatis.spring.annotation.MapperScan; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = { + "spring.sql.init.mode=never", + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false" +}) +@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true") +class CargoDiscardMysqlAcceptanceTest { + private static final String CUSTOMER = "990001"; + + @Autowired private JdbcTemplate jdbc; + @Autowired private CargoDiscardService service; + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class}) + @MapperScan({ + "com.baiyue.lastleg.mapper", + "com.baiyue.lastleg.discard.mapper", + "com.baiyue.lastleg.dropship.mapper" + }) + static class AcceptanceApplication { + } + + @Test + void createApprovalRejectionPendingAndInsufficientStockScenarios() { + resetAcceptanceTenant(); + seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20); + seedProfile(990001101L, 990001001L, "PALLET-A"); + seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12); + seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8); + seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12); + seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8); + + seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10); + seedProfile(990001102L, 990001002L, "PALLET-B"); + seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10); + seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10); + + seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7); + seedProfile(990001103L, 990001003L, "PALLET-C"); + seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7); + seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7); + + var approveRequest = new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3), + new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)), + "ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃"); + var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest); + assertEquals(20L, skuPackages(990001001L)); + approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实")); + assertEquals(CargoDiscardService.APPROVED, approved.summary().status()); + assertEquals(15L, skuPackages(990001001L)); + + var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)), + "ACCEPT-DISCARD-REJECT", "验收:外箱可修复")); + assertEquals(10L, skuPackages(990001002L)); + rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer", + new CargoDiscardRequests.Review("REJECT", "复核后无需废弃")); + assertEquals(CargoDiscardService.REJECTED, rejected.summary().status()); + assertEquals(10L, skuPackages(990001002L)); + + var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)), + "ACCEPT-DISCARD-PENDING", "验收:等待仓库审核")); + assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status()); + assertEquals(7L, skuPackages(990001003L)); + + assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator", + new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)), + "ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败"))); + + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'")); + assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'")); + assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'")); + assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'")); + assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'")); + String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " + + "AND to_status='APPROVED'", String.class, CUSTOMER); + assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15")); + } + + private void resetAcceptanceTenant() { + jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER); + jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER); + } + + private void seedOrder(long id, String inboundCode, String orderNo, int packages) { + jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," + + "source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," + + "package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," + + "inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," + + "'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))", + id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10, + "ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo); + } + + private void seedProfile(long id, long orderId, String palletNo) { + jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," + + "source_pallet_no,status,idempotency_key,version,created_at,updated_at) " + + "VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))", + id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id); + } + + private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) { + jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," + + "package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))", + id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10); + } + + private void seedCartons(long orderId, long profileId, long firstId, String palletNo, + String sku, String markPrefix, int packages) { + for (int index = 1; index <= packages; index++) { + jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," + + "pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " + + "VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))", + firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku, + "ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index); + } + } + + private long skuPackages(long orderId) { + return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?", + Long.class, CUSTOMER, orderId); + } + + private int count(String sql) { + return jdbc.queryForObject(sql, Integer.class, CUSTOMER); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java new file mode 100644 index 000000000..9d4a5bd77 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CargoDiscardSchemaContractTest { + @Test + void baselineContainsDiscardApplicationReservationAndHistoryTables() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_application")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java new file mode 100644 index 000000000..8dc597069 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +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.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardControllerTest { + @Mock CargoDiscardService service; + private CargoDiscardController controller; + + @BeforeEach + void setUp() { + controller = new CargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void reviewRequiresExplicitApprovalPermission() { + AuthenticatedUser user = user(Set.of()); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.review( + user, null, "FH-1", new CargoDiscardRequests.Review("APPROVE", null))); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void merchantTenantComesFromTrustedIdentityWhenApproving() { + AuthenticatedUser user = user(Set.of(CargoDiscardService.APPROVAL_PERMISSION)); + CargoDiscardRequests.Review request = new CargoDiscardRequests.Review("APPROVE", "ok"); + controller.review(user, "999", "FH-1", request); + verify(service).review("100", "FH-1", "7", request); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "reviewer", "MERCHANT", "WEB", permissions); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java new file mode 100644 index 000000000..c3a86f51f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java @@ -0,0 +1,108 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; +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.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardInventoryAdjustmentServiceTest { + @Mock CargoDiscardInventoryMapper inventory; + @Mock DropshipCartonLedgerMapper ledgers; + private CargoDiscardInventoryAdjustmentService service; + + @BeforeEach + void setUp() { + service = new CargoDiscardInventoryAdjustmentService(); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "cartonLedgers", ledgers); + } + + @Test + void discardRequiresExistingTransactionAndConservesPackages() throws Exception { + Transactional transactional = CargoDiscardInventoryAdjustmentService.class + .getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class, + String.class, LocalDateTime.class).getAnnotation(Transactional.class); + assertEquals(Propagation.MANDATORY, transactional.propagation()); + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(3L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + var result = service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()); + + assertEquals(5, result.beforePackages()); + assertEquals(2, result.discardedPackages()); + assertEquals(3, result.afterPackages()); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledgers, times(2)).insert(facts.capture()); + assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType()) + && value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:"))); + } + + @Test + void discardRejectsAnyPackageConservationMismatch() { + CargoDiscardApplication application = application(2); + CargoDiscardItem item = item(2); + when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5)); + when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1); + when(inventory.skuPackages("100", 301L)).thenReturn(4L); + when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1))); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1); + + assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item), + Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now())); + } + + private CargoDiscardApplication application(int packages) { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged"); + return value; + } + private CargoDiscardItem item(int packages) { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1"); + value.setDiscardPackages(packages); return value; + } + private DropshipSkuInventory sku(long packages) { + DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value; + } + private CargoDiscardCarton link(long id) { + CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value; + } + private DropshipCarton carton(long id) { + DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java new file mode 100644 index 000000000..ce9707e49 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -0,0 +1,180 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardCarton; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CargoDiscardServiceTest { + @Mock CargoDiscardApplicationMapper applications; + @Mock CargoDiscardItemMapper items; + @Mock CargoDiscardCartonMapper discardCartons; + @Mock CargoDiscardStatusHistoryMapper histories; + @Mock CargoDiscardInventoryMapper inventory; + @Mock CargoDiscardInventoryAdjustmentService adjustments; + @Mock LastlegOrderMapper orders; + + private CargoDiscardService service; + private final AtomicReference storedApplication = new AtomicReference<>(); + private final List storedItems = new ArrayList<>(); + private final List storedLinks = new ArrayList<>(); + private final List storedHistories = new ArrayList<>(); + + @BeforeEach + void setUp() { + service = new CargoDiscardService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "discardCartons", discardCartons); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "adjustments", adjustments); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00")); + lenient().when(applications.insert(any(CargoDiscardApplication.class))).thenAnswer(invocation -> { storedApplication.set(invocation.getArgument(0)); return 1; }); + lenient().when(items.insert(any(CargoDiscardItem.class))).thenAnswer(invocation -> { storedItems.add(invocation.getArgument(0)); return 1; }); + lenient().when(discardCartons.insert(any(CargoDiscardCarton.class))).thenAnswer(invocation -> { storedLinks.add(invocation.getArgument(0)); return 1; }); + lenient().when(histories.insert(any(CargoDiscardStatusHistory.class))).thenAnswer(invocation -> { storedHistories.add(invocation.getArgument(0)); return 1; }); + lenient().when(applications.findNo(any(), any())).thenAnswer(invocation -> storedApplication.get()); + lenient().when(items.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedItems)); + lenient().when(discardCartons.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedLinks)); + lenient().when(histories.listApplication(any(), anyLong())).thenAnswer(invocation -> List.copyOf(storedHistories)); + } + + @Test + void createReservesExactSkuCartonsWithoutDeductingInventory() { + LastlegOrder order = order(); + DropshipSkuInventory sku = skuInventory(); + when(orders.selectById(101L)).thenReturn(order); + when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L); + when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L))); + when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku); + when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L); + when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1); + + var detail = service.create("100", "7", new CargoDiscardRequests.Create( + List.of(new CargoDiscardRequests.Item(101L, "SKU-1", 2)), "discard-create-1", "damaged")); + + assertEquals(CargoDiscardService.PENDING_REVIEW, detail.summary().status()); + assertEquals(2, detail.summary().totalPackages()); + assertEquals(new BigDecimal("10.00"), detail.summary().totalFee()); + assertEquals(2, storedLinks.size()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + } + + @Test + void approvalDeductsSkuInventoryAndWritesOneLedgerPerCarton() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + storedLinks.add(link(item.getId(), 2L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any())) + .thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1)); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok")); + + assertEquals(CargoDiscardService.APPROVED, detail.summary().status()); + verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any()); + assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1", + storedHistories.get(storedHistories.size() - 1).getRemark()); + } + + @Test + void rejectionReleasesReservedCartonsWithoutDeduction() { + CargoDiscardApplication application = pendingApplication(); + storedApplication.set(application); + CargoDiscardItem item = item(); + storedItems.add(item); + storedLinks.add(link(item.getId(), 1L)); + when(applications.lockNo("100", "FH-1")).thenReturn(application); + when(inventory.changeCartonStatus("100", 1L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1); + + var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("REJECT", "not damaged")); + + assertEquals(CargoDiscardService.REJECTED, detail.summary().status()); + verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt()); + verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any()); + } + + private LastlegOrder order() { + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setCustomerId("100"); order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setInboundCode("RK-1"); order.setOverseasWarehouseId("WH-1"); order.setOverseasWarehouseName("LA"); + return order; + } + + private DropshipSkuInventory skuInventory() { + DropshipSkuInventory value = new DropshipSkuInventory(); + value.setId(301L); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setProductName("Box"); value.setPackageCount(3L); + return value; + } + + private DropshipCarton carton(Long id) { + DropshipCarton value = new DropshipCarton(); + value.setId(id); value.setCustomerId("100"); value.setOrderId(101L); value.setProfileId(201L); + value.setSkuCode("SKU-1"); value.setCartonMark("C-" + id); value.setStatus("DISCARD_PENDING"); + return value; + } + + private CargoDiscardApplication pendingApplication() { + CargoDiscardApplication value = new CargoDiscardApplication(); + value.setId(401L); value.setCustomerId("100"); value.setDiscardNo("FH-1"); value.setInboundCodes("RK-1"); + value.setOverseasWarehouseId("WH-1"); value.setTotalPackages(2); value.setFeePerPackage(new BigDecimal("5.00")); + value.setTotalFee(new BigDecimal("10.00")); value.setCurrency("USD"); value.setStatus(CargoDiscardService.PENDING_REVIEW); + return value; + } + + private CargoDiscardItem item() { + CargoDiscardItem value = new CargoDiscardItem(); + value.setId(501L); value.setApplicationId(401L); value.setCustomerId("100"); value.setOrderId(101L); + value.setInboundCode("RK-1"); value.setProfileId(201L); value.setSkuInventoryId(301L); + value.setSkuCode("SKU-1"); value.setAvailablePackagesSnapshot(3); value.setDiscardPackages(2); + return value; + } + + private CargoDiscardCarton link(Long itemId, Long cartonId) { + CargoDiscardCarton value = new CargoDiscardCarton(); + value.setId(600L + cartonId); value.setApplicationId(401L); value.setItemId(itemId); + value.setCustomerId("100"); value.setCartonId(cartonId); value.setCartonMark("C-" + cartonId); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java new file mode 100644 index 000000000..85548534b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DispatchSchemaContractTest { + @Test + void baselineDefinesDispatchHeadersItemsAndActivePalletLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_item")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock")); + assertTrue(sql.contains("uk_ll_dispatch_active_pallet")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation")); + assertTrue(sql.contains("active_key")); + assertTrue(sql.contains("order_refs")); + } + + @Test + void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception { + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java")); + assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation")); + assertTrue(mapper.contains("ta.status='RESERVED'")); + assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java new file mode 100644 index 000000000..675bf5e58 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchPdaWorkflowServiceTest { + @Mock private DispatchOrderMapper orders; + @Mock private DispatchPdaOperationMapper operations; + @Mock private OutboundOrderMapper outboundOrders; + + @Test + void pushRequiresVehiclePlateBeforeCreatingPdaTask() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void pushRequiresWarehouseSelection() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); + order.setOverseasWarehouseId("WH-1"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.push("100", "PC1", new DispatchPdaRequests.Push("push-1", null, null))); + + assertEquals(400, exception.getStatusCode().value()); + } + + @Test + void departRequiresVehiclePlate() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate(" "); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departRequiresPendingOutboundOrder() { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); + order.setStatus("READY_TO_DEPART"); order.setVehiclePlate("CA-100"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + when(operations.findKey("100", "depart-1")).thenReturn(null); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(null); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.depart("100", "PC1", "operator", new DispatchPdaRequests.Depart("depart-1", null))); + + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void departedRequestReplaysAfterStatusChanged() throws Exception { + DispatchPdaWorkflowService service = new DispatchPdaWorkflowService(); + ObjectMapper objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + DispatchOrder order = new DispatchOrder(); + order.setId(1L); order.setCustomerId("100"); order.setDispatchNo("PC1"); order.setStatus("DEPARTED"); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(order); + when(orders.lockById("100", 1L)).thenReturn(order); + DispatchPdaOperation operation = new DispatchPdaOperation(); + operation.setOperationType("DEPART"); operation.setStatus("COMPLETED"); + operation.setResultJson(objectMapper.writeValueAsString(new DispatchPdaResponses.Departed("PC1", "DEPARTED", 12, false))); + when(operations.findKey("100", "depart-1")).thenReturn(operation); + + // This exact key/hash pair models the persisted first execution result. + DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); + operation.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + DispatchPdaResponses.Departed result = service.depart("100", "PC1", "operator", request); + + assertEquals("DEPARTED", result.status()); + assertEquals(true, result.replayed()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java new file mode 100644 index 000000000..ad9dcee13 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.pda.service; + +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPickOrder; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickOrderMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DispatchPickOrderAttributionServiceTest { + @Mock private DispatchPdaPickOrderMapper attributions; + @Mock private LastlegOrderMapper orders; + @Mock private LastlegOrderStatusHistoryMapper history; + + @Test + void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException { + Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped", + String.class, Long.class, long.class, String.class).getAnnotation(Update.class); + String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " "); + + assertTrue(sql.indexOf("current_status=CASE") + < sql.indexOf("shipped_package_count=shipped_package_count+")); + } + + @Test + void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() { + DispatchPickOrderAttributionService service = service(); + InboundStockReceipt first = receipt(11L, 101L, 3L, 30L); + InboundStockReceipt second = receipt(12L, 102L, 4L, 40L); + when(attributions.availableReceipts("t1", 91L)).thenReturn(List.of(first, second)); + when(attributions.attributedPackages(eq("t1"), anyLong())).thenReturn(0L); + when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); + DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); + + List result = service.attribute("t1", pick); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), + new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); + } + + @Test + void departureMovesOnlyAttributedPackagesAndWritesOrderHistory() { + DispatchPickOrderAttributionService service = service(); + DispatchPdaPick pick = pick(81L, 91L, 4L, 40L); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setOrderId(101L); row.setPackages(4L); + when(attributions.listPick("t1", 81L)).thenReturn(List.of(row)); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setPackageCount(10); order.setPickedPackageCount(4); + order.setShippedPackageCount(2); order.setCurrentStatus("INBOUND_COMPLETED"); + when(orders.lockById("t1", 101L)).thenReturn(order); + when(orders.transferPickedToShipped("t1", 101L, 4L, "OUT1")).thenReturn(1); + + List result = service.depart( + "t1", "OUT1", "D1", List.of(pick), LocalDateTime.of(2026, 8, 17, 15, 0)); + + assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 4L)), result); + verify(history).insert(argThat((LastlegOrderStatusHistory value) -> "PARTIALLY_SHIPPED".equals(value.getToStatus()) + && value.getRemark().contains("6/10"))); + } + + private DispatchPickOrderAttributionService service() { + DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService(); + ReflectionTestUtils.setField(service, "attributions", attributions); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "history", history); + return service; + } + + private static InboundStockReceipt receipt(long id, long orderId, long packages, long quantity) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setId(id); receipt.setOrderId(orderId); receipt.setReceivedPackages(packages); + return receipt; + } + + private static DispatchPdaPick pick(long id, long positionId, long packages, long quantity) { + DispatchPdaPick pick = new DispatchPdaPick(); + pick.setId(id); pick.setInventoryPositionId(positionId); pick.setPackages(packages); + pick.setProductQuantity(quantity); pick.setPickedAt(LocalDateTime.of(2026, 8, 17, 14, 0)); + return pick; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java new file mode 100644 index 000000000..5991a2d7b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -0,0 +1,112 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DispatchServiceTest { + @Mock DispatchOrderMapper orders; + @Mock DispatchItemMapper items; + @Mock DispatchPalletLockMapper locks; + @Mock DispatchCandidateMapper candidates; + private DispatchService service; + + @BeforeEach + void setUp() { + service = new DispatchService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "candidates", candidates); + lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + } + + @Test + void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception { + assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, + DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + + var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null)); + + assertEquals(DispatchService.PENDING_DEPARTURE, result.status()); + assertEquals("ONT8", result.route()); + ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); + verify(orders).insert(dispatch.capture()); + assertEquals(1, dispatch.getValue().getPalletCount()); + verify(locks).insert(any(DispatchPalletLock.class)); + verify(items).insert(any(DispatchItem.class)); + } + + @Test + void rejectsPalletAlreadyLockedByAnotherDispatch() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + DispatchPalletLock locked = new DispatchPalletLock(); + when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked); + + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + verify(orders, never()).insert(any(DispatchOrder.class)); + } + + @Test + void invalidCandidateStageIsRejected() { + assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any()); + } + + @Test + void editCannotBypassPdaManagedDispatchStatus() { + DispatchOrder dispatch = new DispatchOrder(); + dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); + dispatch.setStatus(DispatchService.PENDING_DEPARTURE); + when(orders.findByDispatchNo("100", "PC1")).thenReturn(dispatch); + when(orders.lockById("100", 1L)).thenReturn(dispatch); + DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, null, + LocalDateTime.now(), null, null, null, null, "COMPLETED"); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.update("100", "PC1", request)); + + assertEquals(400, exception.getStatusCode().value()); + assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); + verify(orders, never()).updateById(any(DispatchOrder.class)); + } + + private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { + return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", + "LIP1-P001", destination, destination, destination, destination, 12, + new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java new file mode 100644 index 000000000..adf1944e5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dropship; + +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 DropshipSchemaContractTest { + @Test + void baselineAndIncrementalSqlKeepContainerAndMultiPalletIndexes() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + + assertThat(baseline).contains( + "key idx_ll_order_container(customer_id,container_no)", + "unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)", + "key idx_ll_dropship_profile_order(customer_id,order_id)", + "warehouse_pallet_id bigint null comment '仓库实物托盘id'", + "pallet_no varchar(128) null comment '托盘号'", + "status varchar(32) not null default 'pending_inbound'"); + assertThat(migration).contains( + "add key idx_ll_order_container (customer_id,container_no)", + "add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)", + "add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)", + "add key idx_ll_dropship_profile_order(customer_id,order_id)", + "modify column warehouse_pallet_id bigint null", + "modify column pallet_no varchar(128) null"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java new file mode 100644 index 000000000..85564aa1f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -0,0 +1,148 @@ +package com.baiyue.lastleg.dropship.service; + +import com.baiyue.lastleg.dropship.dto.DropshipRequests; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class DropshipOrderServiceTest { + @Mock private LastlegOrderMapper orders; + @Mock private DropshipOrderProfileMapper profiles; + @Mock private DropshipSkuInventoryMapper skuInventory; + @Mock private DropshipCartonMapper cartons; + @Mock private DropshipCartonLedgerMapper ledger; + @Mock private WarehouseResourceGateway warehouse; + @InjectMocks private DropshipOrderService service; + + @Test + void receivesMultiplePalletsForOneInboundOrder() { + LastlegOrder order = new LastlegOrder(); + order.setId(10L); + order.setCustomerId("C-1"); + order.setOrderType("ONE_CLICK_DROPSHIP"); + order.setOrderNo("LL-10"); + order.setPackageCount(3); + order.setProductQuantity(30); + when(orders.selectById(10L)).thenReturn(order); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of()); + + DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1", + List.of( + new DropshipRequests.Pallet("SOURCE-P-1", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 2, 20, + List.of("MARK-1", "MARK-2")))), + new DropshipRequests.Pallet("SOURCE-P-2", List.of( + new DropshipRequests.SkuPackages("SKU-A", "Product A", 1, 10, + List.of("MARK-3")))))); + + assertThat(service.receive(request)).isEqualTo(3); + verifyNoInteractions(warehouse); + verify(profiles, times(2)).insert(any(DropshipOrderProfile.class)); + verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class)); + verify(cartons, times(3)).insert(any(DropshipCarton.class)); + verify(ledger, times(3)).insert(any(DropshipCartonLedger.class)); + + ArgumentCaptor profilesCaptor = ArgumentCaptor.forClass(DropshipOrderProfile.class); + verify(profiles, times(2)).insert(profilesCaptor.capture()); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getSourcePalletNo) + .containsExactly("SOURCE-P-1", "SOURCE-P-2"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId) + .containsOnly("C-1"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus) + .containsOnly("PENDING_INBOUND"); + assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId) + .containsOnlyNulls(); + ArgumentCaptor cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class); + verify(cartons, times(3)).insert(cartonCaptor.capture()); + assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus) + .containsOnly("PENDING_INBOUND"); + ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(3)).insert(ledgerCaptor.capture()); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType) + .containsOnly("SNAPSHOT_CREATED"); + assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages) + .containsOnly(0L); + } + + @Test + void activatesMultiplePalletsOnlyAfterPdaStockIn() { + DropshipOrderProfile first = profile(1L, "SOURCE-P-1"); + DropshipOrderProfile second = profile(2L, "SOURCE-P-2"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second)); + DropshipCarton firstCarton = carton(11L, 1L, "MARK-1"); + DropshipCarton secondCarton = carton(12L, 2L, "MARK-2"); + when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton)); + when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); + + service.activateAfterStockIn("C-1", 10L, 50L, List.of( + receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1))); + + assertThat(first.getStatus()).isEqualTo("ACTIVE"); + assertThat(first.getWarehousePalletId()).isEqualTo(101L); + assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE"); + assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L); + verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class)); + verify(cartons, times(2)).updateById(any(DropshipCarton.class)); + ArgumentCaptor facts = ArgumentCaptor.forClass(DropshipCartonLedger.class); + verify(ledger, times(2)).insert(facts.capture()); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN"); + assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L); + } + + @Test + void repeatedPdaActivationIsANoOp() { + DropshipOrderProfile active = profile(1L, "SOURCE-P-1"); + active.setStatus("ACTIVE"); + when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active)); + + service.activateAfterStockIn("C-1", 10L, 50L, + List.of(receipt(101L, "WH-P-1", 201L, 1))); + + verify(profiles, never()).updateById(any(DropshipOrderProfile.class)); + verify(cartons, never()).updateById(any(DropshipCarton.class)); + verify(ledger, never()).insert(any(DropshipCartonLedger.class)); + } + + private static DropshipOrderProfile profile(long id, String sourcePalletNo) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L); + profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L); + return profile; + } + + private static DropshipCarton carton(long id, long profileId, String mark) { + DropshipCarton carton = new DropshipCarton(); + carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId); + carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); + return carton; + } + + private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) { + InboundStockReceipt receipt = new InboundStockReceipt(); + receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo); + receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages); + return receipt; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java new file mode 100644 index 000000000..32b31ef38 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InboundPlanSchemaContractTest { + @Test + void planIsUniqueByTenantAndActualContainerNumber() throws IOException { + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertTrue(stream != null, "lastleg schema must be available"); + String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)")); + assertTrue(schema.contains("default 'pending_assignment'")); + assertTrue(schema.contains("required_package_count")); + assertTrue(schema.contains("planned_package_count")); + assertTrue(schema.contains("warehouse_resource_lock_id")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_task")); + assertTrue(schema.contains("create table if not exists ll_inbound_pda_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_pallet_merge")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); + assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); + assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); + assertTrue(schema.contains("fba_warehouse_code")); + assertFalse(schema.contains("cabinet_no")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java new file mode 100644 index 000000000..1e2c79ed6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -0,0 +1,374 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPdaWorkflowServiceTest { + @Mock PdaWorkflowRepository repository; + @Mock WarehouseResourceGateway warehouse; + @Mock InboundStockReceiptService stockReceipts; + private InboundPdaWorkflowService service; + + private InboundPlan plan; + private InboundPdaTask task; + private InboundPlanLine targetLine; + private InboundPlanLine sourceLine; + private InboundPlanPallet targetPallet; + private InboundPlanPallet sourcePallet; + private InboundPlanPallet otherWarehousePallet; + private InboundPlanPalletAllocation target; + private InboundPlanPalletAllocation source; + private InboundPlanPalletAllocation otherWarehouse; + + @BeforeEach + void setUp() { + service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); + service.initializeObjectMapper(); + plan = plan(); + task = task(); + targetLine = line(11L, 101L, "RK-A", "1.0000", 5); + sourceLine = line(12L, 102L, "RK-B", "0.5000", 2); + targetPallet = pallet(21L, "WH-A", 3, "0.6000", 901L); + sourcePallet = pallet(22L, "WH-A", 2, "0.5000", 902L); + otherWarehousePallet = pallet(23L, "WH-B", 4, "0.8000", 903L); + target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); + source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); + otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); + when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + } + + @Test + void pushRequiresEveryPalletToBeAssignedAndLocked() { + targetPallet.setWarehouseResourceLockId(null); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + assertThrows(ResponseStatusException.class, + () -> service.push("100", "LIP1", new PdaRequests.Push("push-1", "7", "worker"))); + + verify(repository, never()).insert(any(InboundPdaTask.class)); + } + + @Test + void assignmentReservesWarehouseResourcesAndPersistsTheLock() { + targetPallet.setDestinationWarehouseId("88"); + targetPallet.setWarehousePalletId(null); + targetPallet.setWarehouseLocationId(null); + targetPallet.setWarehouseResourceLockId(null); + targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1")) + .thenReturn(999L); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "assign-1")); + + assertEquals(188L, targetPallet.getWarehousePalletId()); + assertEquals(288L, targetPallet.getWarehouseLocationId()); + assertEquals(999L, targetPallet.getWarehouseResourceLockId()); + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(repository).update(targetPallet); + } + + @Test + void repeatedAssignmentIsIdempotentAndWrongDestinationIsRejected() { + targetPallet.setDestinationWarehouseId("88"); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-retry")); + + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), + any(), any(), any()); + assertThrows(ResponseStatusException.class, () -> service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(99L, targetPallet.getWarehousePalletId(), + targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse"))); + } + + @Test + void countMustPrecedePickAndPickedBoxesDriveActualVolume() { + activeTask(); + target.setCountedPackageCount(0); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "pick-before-count", "7", "worker"))); + + service.count("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(3, "count-3", "7", "worker")); + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(2, "pick-2", "7", "worker")); + + assertEquals("PICKED", result.result()); + assertEquals(2, target.getPickedPackageCount()); + assertEquals("0.4000", target.getActualVolumeCbm().toPlainString()); + assertEquals(5, targetPallet.getActualPackageCount()); + assertEquals("1.0000", targetPallet.getActualVolumeCbm().toPlainString()); + } + + @Test + void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + candidateQueries(); + + var result = service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(5, "pick-over", "7", "worker")); + + assertEquals("MERGE_CONFIRMATION_REQUIRED", result.result()); + assertEquals(3, result.remainingPackageCount()); + assertEquals(2, result.mergePackageCount()); + assertEquals(1, result.candidates().size()); + assertEquals(source.getId(), result.candidates().get(0).allocationId()); + assertEquals(0, target.getPickedPackageCount()); + verify(repository, never()).update(target); + verify(repository, never()).update(targetPallet); + verify(repository).insert(any(InboundPdaOperation.class)); + } + + @Test + void mergeCandidatesOnlyIncludeSameDestinationPallets() { + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + candidateQueries(); + + var result = service.mergeCandidates("100", "LIP1", target.getId()); + + assertEquals(1, result.size()); + assertEquals("RK-B", result.get(0).inboundCode()); + assertEquals("0.5000", result.get(0).availableVolumeCbm().toPlainString()); + } + + @Test + void confirmedMergePersistsBothOrdersAndScrapsAnEmptiedSourcePallet() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source)); + + var result = service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 2, "merge-1", "7", "worker")); + + assertEquals("MERGED", result.result()); + assertEquals(sourceLine.getOrderId(), result.sourceOrderId()); + assertEquals(targetLine.getOrderId(), result.targetOrderId()); + assertTrue(result.sourcePalletScrapped()); + assertEquals(0, sourcePallet.getActualPackageCount()); + assertEquals(5, targetPallet.getActualPackageCount()); + verify(warehouse).scrap("100", 902L); + ArgumentCaptor merge = ArgumentCaptor.forClass(InboundPalletMerge.class); + verify(repository).insert(merge.capture()); + assertEquals(sourceLine.getOrderId(), merge.getValue().getSourceOrderId()); + assertEquals(targetLine.getOrderId(), merge.getValue().getTargetOrderId()); + assertTrue(merge.getValue().getSourcePalletScrapped()); + } + + @Test + void mergeCannotExceedTargetPalletVolumeCapacity() { + activeTask(); + targetPallet.setActualVolumeCbm(new BigDecimal("1.9000")); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.lockAllocation(plan.getId(), source.getId())).thenReturn(source); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lockPallet(plan.getId(), sourcePallet.getId())).thenReturn(sourcePallet); + when(repository.line(plan.getId(), sourceLine.getId())).thenReturn(sourceLine); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + + assertThrows(ResponseStatusException.class, () -> service.merge("100", "LIP1", target.getId(), + new PdaRequests.ConfirmMerge(source.getId(), 1, "merge-over-capacity", "7", "worker"))); + + verify(warehouse, never()).scrap(any(), anyLong()); + verify(repository, never()).insert(any(InboundPalletMerge.class)); + } + + @Test + void completionRequiresExactPickedTotalForEveryLine() { + activeTask(); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1")); + assertFalse("COMPLETED".equals(task.getStatus())); + verify(repository, never()).update(plan); + } + + @Test + void completesWhenMergedAllocationsPreserveExactLineTotals() { + activeTask(); + target.setPickedPackageCount(3); + InboundPlanPalletAllocation moved = allocation(34L, targetLine.getId(), sourcePallet.getId(), 0, 2, 2); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, moved)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + assertEquals("COMPLETED", plan.getStatus()); + verify(repository).update(task); + verify(repository).update(plan); + verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), + eq(List.of(target, moved)), any(), any()); + } + + @Test + void completedTaskReplayDoesNotRunStockInAgain() { + task.setStatus("COMPLETED"); + activeTask(); + + var result = service.complete("100", "LIP1"); + + assertEquals("COMPLETED", result.status()); + verify(stockReceipts, never()).stockIn(any(), any(), any(), any(), any(), any(), any()); + verify(repository, never()).allocations(plan.getId()); + } + + @Test + void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { + InboundPdaOperation existing = new InboundPdaOperation(); + existing.setAction("COUNT"); + existing.setRequestHash("different"); + when(repository.operation("100", "same-key")).thenReturn(existing); + + assertThrows(ResponseStatusException.class, () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "same-key", "7", "worker"))); + } + + @Test + void completedTaskStillReplaysAnAlreadyPersistedPick() { + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + PdaRequests.PackageOperation request = new PdaRequests.PackageOperation(1, "pick-replay", "7", "worker"); + + var first = service.pick("100", "LIP1", target.getId(), request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(operation.capture()); + task.setStatus("COMPLETED"); + when(repository.operation("100", "pick-replay")).thenReturn(operation.getValue()); + + var replay = service.pick("100", "LIP1", target.getId(), request); + + assertEquals(first, replay); + assertEquals(1, target.getPickedPackageCount()); + verify(repository, times(1)).lockAllocation(plan.getId(), target.getId()); + } + + private void activeTask() { when(repository.lockTask("100", plan.getId())).thenReturn(task); } + + private void candidateQueries() { + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet, sourcePallet, otherWarehousePallet)); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine, sourceLine)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target, source, otherWarehouse)); + } + + private InboundPlan plan() { + InboundPlan value = new InboundPlan(); + value.setId(1L); + value.setCustomerId("100"); + value.setPlanNo("LIP1"); + value.setStatus("ASSIGNED"); + return value; + } + + private InboundPdaTask task() { + InboundPdaTask value = new InboundPdaTask(); + value.setId(2L); + value.setPlanId(1L); + value.setCustomerId("100"); + value.setTaskNo("PDA2"); + value.setStatus("PDA_PENDING"); + value.setVersion(0); + return value; + } + + private InboundPlanLine line(long id, long orderId, String inboundCode, String volume, int packages) { + InboundPlanLine value = new InboundPlanLine(); + value.setId(id); + value.setPlanId(1L); + value.setOrderId(orderId); + value.setInboundCode(inboundCode); + value.setDestinationWarehouseId("WH-A"); + value.setVolumeCbm(new BigDecimal(volume)); + value.setRequiredPackageCount(packages); + return value; + } + + private InboundPlanPallet pallet(long id, String warehouseId, int actualPackages, + String actualVolume, long lockId) { + InboundPlanPallet value = new InboundPlanPallet(); + value.setId(id); + value.setPlanId(1L); + value.setPalletNo("PLT-" + id); + value.setDestinationWarehouseId(warehouseId); + value.setCapacityCbm(new BigDecimal("2.0000")); + value.setWarehousePalletId(id + 1000); + value.setWarehouseLocationId(id + 2000); + value.setWarehouseResourceLockId(lockId); + value.setAssignmentStatus("ASSIGNED"); + value.setActualPackageCount(actualPackages); + value.setActualVolumeCbm(new BigDecimal(actualVolume)); + return value; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, + int planned, int counted, int picked) { + InboundPlanPalletAllocation value = new InboundPlanPalletAllocation(); + value.setId(id); + value.setPlanId(1L); + value.setLineId(lineId); + value.setPalletId(palletId); + value.setPlannedPackageCount(planned); + value.setCountedPackageCount(counted); + value.setPickedPackageCount(picked); + value.setActualVolumeCbm(BigDecimal.ZERO); + value.setVersion(0); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java new file mode 100644 index 000000000..5ab60faa6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -0,0 +1,214 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; +import com.baiyue.lastleg.inboundplan.pda.mapper.InboundStockReceiptMapper; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +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.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundStockReceiptServiceTest { + @Mock LastlegOrderMapper orders; + @Mock InboundStockReceiptMapper receipts; + @Mock InventoryCommandService inventory; + @Mock DropshipOrderService dropshipOrders; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + private InboundStockReceiptService service; + + @BeforeEach + void setUp() { + service = new InboundStockReceiptService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "receipts", receipts); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + when(receipts.listTask("100", 2L)).thenReturn(List.of()); + } + + @Test + void writesOneReceiptPerSkuAndPalletAndUsesPdaPackageCount() { + Fixture fixture = fixture(5, 5, 5, 10); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + ArgumentCaptor request = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("100"), eq("operator"), request.capture()); + assertEquals(5L, request.getValue().packages()); + assertEquals(101L, request.getValue().inboundOrderId()); + assertEquals("IN-1", request.getValue().inboundCode()); + ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); + verify(receipts).insert(receipt.capture()); + assertEquals(5L, receipt.getValue().getReceivedPackages()); + assertEquals(10L, receipt.getValue().getInventoryPositionId()); + assertEquals("INBOUND_COMPLETED", fixture.order.getCurrentStatus()); + assertEquals("INBOUND_PLAN:COMPLETED", fixture.order.getSubOrderStatus()); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() { + Fixture fixture = fixture(10, 5, 5, 10); + fixture.detail.setPackageCount(5); + LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation, secondAllocation), + List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), any()); + verify(receipts, times(2)).insert(any(InboundStockReceipt.class)); + } + + @Test + void conservesSingleSkuPackagesAndQuantityAcrossPallets() { + Fixture fixture = fixture(5, 2, 2, 11); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 3); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); + verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); + assertEquals(5L, requests.getAllValues().stream().mapToLong(InventoryCommandRequests.StockIn::packages).sum()); + } + + @Test + void existingTaskReceiptMakesStockInReplayANoOp() { + Fixture fixture = fixture(10, 5, 5, 10); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + InboundStockReceipt firstReceipt = new InboundStockReceipt(); + firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId()); + InboundStockReceipt secondReceipt = new InboundStockReceipt(); + secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId()); + when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(receipts, never()).insert(any(InboundStockReceipt.class)); + } + + @Test + void partialTaskReceiptsAreRejected() { + Fixture fixture = fixture(10, 5, 5, 10); + InboundPlanPallet secondPallet = pallet(22L, 2002L); + InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); + InboundStockReceipt partial = new InboundStockReceipt(); + partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId()); + when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial)); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.stockIn("100", fixture.plan, fixture.task, + List.of(fixture.line), List.of(fixture.allocation, secondAllocation), + List.of(fixture.pallet, secondPallet), LocalDateTime.now())); + assertEquals(409, exception.getStatusCode().value()); + + verify(inventory, never()).stockIn(any(), any(), any()); + verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); + } + + @Test + void activatesDropshipAfterGenericStockInReceiptIsCreated() { + Fixture fixture = fixture(5, 5, 5, 10); + fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) + .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); + + service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now()); + + ArgumentCaptor> activationReceipts = ArgumentCaptor.forClass(List.class); + verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture()); + assertEquals(1, activationReceipts.getValue().size()); + assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId()); + } + + private Fixture fixture(int required, int planned, int picked, int quantity) { + InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); + InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); + InboundPlanLine line = new InboundPlanLine(); line.setId(12L); line.setPlanId(1L); line.setOrderId(101L); + line.setInboundCode("IN-1"); line.setDestinationWarehouseId("WH-DEST"); + line.setDestinationWarehouseCode("ONT8"); line.setDestinationWarehouseName("ONT8"); + line.setRequiredPackageCount(required); line.setVolumeCbm(new BigDecimal("1.0")); + LastlegOrder order = new LastlegOrder(); order.setId(101L); order.setCustomerId("100"); + order.setOrderNo("LMO-1"); order.setCustomerName("Customer"); order.setOverseasWarehouseId("OW-1"); + order.setOverseasWarehouseName("Warehouse"); order.setSourceEventId("evt-1"); order.setFbaWarehouseCode("ONT8"); + order.setCurrentStatus("INBOUND_PLANNED"); + org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); + LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); + return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L)); + } + + private LastlegOrderDetail detail(long id, String sku, int packages, int quantity) { + LastlegOrderDetail detail = new LastlegOrderDetail(); detail.setId(id); detail.setOrderId(101L); + detail.setSkuCode(sku); detail.setPackageCount(packages); detail.setProductQuantity(quantity); + detail.setProductNameZh(sku + " name"); return detail; + } + + private InboundPlanPallet pallet(long id, long warehousePalletId) { + InboundPlanPallet pallet = new InboundPlanPallet(); pallet.setId(id); pallet.setPlanId(1L); + pallet.setPalletNo("PLT-" + id); pallet.setWarehousePalletId(warehousePalletId); + pallet.setWarehouseLocationId(3001L); return pallet; + } + + private InboundPlanPalletAllocation allocation(long id, long lineId, long palletId, int picked) { + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); allocation.setId(id); + allocation.setPlanId(1L); allocation.setLineId(lineId); allocation.setPalletId(palletId); + allocation.setPlannedPackageCount(picked); allocation.setPickedPackageCount(picked); return allocation; + } + + private record Fixture(InboundPlan plan, InboundPdaTask task, InboundPlanLine line, LastlegOrder order, + LastlegOrderDetail detail, InboundPlanPalletAllocation allocation, + InboundPlanPallet pallet) {} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java new file mode 100644 index 000000000..0430ca248 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -0,0 +1,137 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.model.FbaAppointmentLineSnapshot; +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.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundFbaAppointmentServiceTest { + @Mock InboundFbaAppointmentMapper appointmentMapper; + @Mock InboundFbaAppointmentItemMapper itemMapper; + @Mock InboundFbaAppointmentQueryMapper queryMapper; + + private InboundFbaAppointmentService service; + private final LocalDateTime appointmentAt = LocalDateTime.of(2026, 8, 20, 10, 30); + + @BeforeEach + void setUp() { + service = new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service, "appointmentMapper", appointmentMapper); + ReflectionTestUtils.setField(service, "itemMapper", itemMapper); + ReflectionTestUtils.setField(service, "queryMapper", queryMapper); + lenient().when(itemMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void createsOneBatchForSelectedLinesAndFreezesFbaWarehouse() throws Exception { + assertEquals(true, InboundFbaAppointmentService.class + .getMethod("batchCreate", String.class, String.class, BatchFbaAppointmentRequest.class) + .getAnnotation(Transactional.class) != null); + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "ONT8"))); + + var result = service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1")); + + assertEquals("APT-1", result.appointmentNo()); + assertEquals("ONT8", result.fbaWarehouseCode()); + assertEquals(2, result.selectedCount()); + verify(appointmentMapper).insert(any(InboundFbaAppointment.class)); + verify(itemMapper, org.mockito.Mockito.times(2)).insert(any(InboundFbaAppointmentItem.class)); + } + + @Test + void rejectsMixedFbaWarehouses() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of( + line(11L, "IN-11", "ONT8"), line(12L, "IN-12", "LAX9"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void rejectsMissingLineFromAnotherTenant() { + when(queryMapper.lockLines("100", List.of(11L, 12L))).thenReturn(List.of(line(11L, "IN-11", "ONT8"))); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 12L), "APT-1"))); + } + + @Test + void duplicateLineIdsAreRejectedBeforeDatabaseAccess() { + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(11L, 11L), "APT-1"))); + verify(queryMapper, never()).lockLines(any(), any()); + } + + @Test + void sameAppointmentReplayReturnsExistingResult() { + InboundFbaAppointment existing = appointment(99L, "APT-1", "ONT8"); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + item.setInboundCode("IN-11"); + item.setFbaWarehouseCode("ONT8"); + when(appointmentMapper.selectOne(any())).thenReturn(existing); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + var result = service.batchCreate("100", "7", request(List.of(11L), "APT-1")); + + assertEquals(99L, result.id()); + verify(appointmentMapper, never()).insert(any(InboundFbaAppointment.class)); + } + + @Test + void sameAppointmentNumberWithDifferentSelectionConflicts() { + when(appointmentMapper.selectOne(any())).thenReturn(appointment(99L, "APT-1", "ONT8")); + InboundFbaAppointmentItem item = new InboundFbaAppointmentItem(); + item.setLineId(11L); + when(itemMapper.selectList(any())).thenReturn(List.of(item)); + + assertThrows(ResponseStatusException.class, + () -> service.batchCreate("100", "7", request(List.of(12L), "APT-1"))); + } + + private BatchFbaAppointmentRequest request(List lineIds, String number) { + return new BatchFbaAppointmentRequest("100", lineIds, number, appointmentAt, "SUCCESS", "dock"); + } + + private FbaAppointmentLineSnapshot line(Long id, String inboundCode, String fba) { + return new FbaAppointmentLineSnapshot(id, 1000L, id + 100L, inboundCode, fba); + } + + private InboundFbaAppointment appointment(Long id, String number, String fba) { + InboundFbaAppointment value = new InboundFbaAppointment(); + value.setId(id); + value.setCustomerId("100"); + value.setAppointmentNo(number); + value.setAppointmentAt(appointmentAt); + value.setAppointmentStatus("SUCCESS"); + value.setAppointmentRemark("dock"); + value.setFbaWarehouseCode(fba); + value.setSelectedCount(1); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java new file mode 100644 index 000000000..179612fe1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundPlanServiceTest { + @Mock InboundPlanMapper planMapper; + @Mock InboundPlanLineMapper lineMapper; + @Mock InboundPlanPalletMapper palletMapper; + @Mock InboundPlanPalletAllocationMapper allocationMapper; + @Mock InboundPlanOrderMapper orderMapper; + + private InboundPlanService service; + + @BeforeEach + void setUp() { + service = new InboundPlanService(); + ReflectionTestUtils.setField(service, "planMapper", planMapper); + ReflectionTestUtils.setField(service, "lineMapper", lineMapper); + ReflectionTestUtils.setField(service, "palletMapper", palletMapper); + ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper); + ReflectionTestUtils.setField(service, "orderMapper", orderMapper); + org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); + org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + void convertsWholeContainerInOneTransaction() throws Exception { + assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class) + .getAnnotation(Transactional.class)); + when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of( + order(1L, "RK-1", "1.5000", "WH-A"), + order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + verify(planMapper).insert(plan.capture()); + assertEquals(2, plan.getValue().getLineCount()); + assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); + assertEquals(2, plan.getValue().getPalletCount()); + assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm()); + verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + ArgumentCaptor lines = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper, times(2)).insert(lines.capture()); + assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum()); + ArgumentCaptor allocations = + ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); + verify(allocationMapper, times(3)).insert(allocations.capture()); + assertEquals(25, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum()); + assertEquals(0, allocations.getAllValues().stream() + .mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum()); + assertEquals(3, allocations.getAllValues().stream() + .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); + verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any()); + verify(orderMapper).lockContainerOrders("100", "CONT-1"); + assertEquals("CONT-1", result.containerNo()); + } + + @Test + void repeatedConversionReturnsExistingPlanWithoutWriting() { + InboundPlan existing = new InboundPlan(); + existing.setId(9L); + existing.setPlanNo("LIP9"); + existing.setCustomerId("100"); + existing.setContainerNo("CONT-1"); + existing.setStatus("PLANNED"); + existing.setLineCount(1); + existing.setPalletCount(1); + existing.setTotalVolumeCbm(BigDecimal.ONE); + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing); + + assertEquals("LIP9", service.convert("100", 1L).planNo()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + } + + @Test + void rejectsMissingDestinationWarehouse() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + @Test + void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { + when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); + SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", + "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); + when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + + assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { + return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, + new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), + warehouse, warehouse, warehouse, 1, 0); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java new file mode 100644 index 000000000..663404360 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.inboundplan.service; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PalletAllocatorTest { + @Test + void roundsEachDestinationGroupUpToTwoCbmPallets() { + assertEquals(2, allocate("4.0000").pallets().size()); + assertEquals(3, allocate("4.0100").pallets().size()); + assertEquals(1, allocate("0.1000").pallets().size()); + assertEquals(0, allocate("0.0000").pallets().size()); + } + + @Test + void neverMixesDestinationWarehousesAndCanSplitOneLine() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("2.5000"), 5), + new PalletAllocator.LineInput(2L, "WH-B", new BigDecimal("0.1000"), 1))); + + assertEquals(3, result.pallets().size()); + assertEquals(2, result.allocations().stream().filter(item -> item.orderId() == 1L).count()); + assertTrue(result.allocations().stream().allMatch(item -> + item.pallet().warehouseKey().equals(item.orderId() == 1L ? "WH-A" : "WH-B"))); + } + + @Test + void distributesPackagesProportionallyAndAssignsRemainderToLastPallet() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 10))); + + assertEquals(List.of(4, 4, 2), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + assertEquals(10, result.allocations().stream() + .mapToInt(PalletAllocator.AllocationDraft::plannedPackageCount).sum()); + } + + @Test + void doesNotLosePackagesWhenEarlyProportionalSharesRoundDown() { + PalletAllocator.Result result = PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal("4.0100"), 1))); + + assertEquals(List.of(0, 0, 1), result.allocations().stream() + .map(PalletAllocator.AllocationDraft::plannedPackageCount).toList()); + } + + private PalletAllocator.Result allocate(String volume) { + return PalletAllocator.allocate(List.of( + new PalletAllocator.LineInput(1L, "WH-A", new BigDecimal(volume), + new BigDecimal(volume).signum() == 0 ? 0 : 1))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java new file mode 100644 index 000000000..a5f96dab4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.inventory; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InventorySchemaContractTest { + @Test + void baselineDefinesInventoryDimensionsStatesAndIdempotency() throws IOException { + String schema; + try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { + assertNotNull(stream, "lastleg schema must be available"); + schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); + } + + assertTrue(schema.contains("create table if not exists ll_inventory_balance")); + assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)")); + assertTrue(schema.contains("inbound_order_id bigint not null")); + assertTrue(schema.contains("inbound_code varchar(128) not null")); + assertTrue(schema.contains("available_packages bigint not null default 0")); + assertTrue(schema.contains("in_transit_packages bigint not null default 0")); + assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity")); + assertTrue(schema.contains("create table if not exists ll_inventory_position")); + assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)")); + assertTrue(schema.contains("warehouse_location_code varchar(128) not null")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("sku_code")); + assertFalse(tableDefinition(schema, "ll_inventory_position").contains("product_quantity")); + assertTrue(schema.contains("create table if not exists ll_inventory_ledger")); + assertTrue(schema.contains("business_order_id bigint null")); + assertTrue(schema.contains("inbound_code varchar(128) null")); + assertTrue(schema.contains("before_packages bigint not null")); + assertTrue(schema.contains("delta_packages bigint not null")); + assertTrue(schema.contains("after_packages bigint not null")); + assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_ledger").contains("carton_mark")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("before_quantity")); + assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("create table if not exists ll_inventory_operation")); + assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt")); + assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)")); + assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); + } + + @Test + void inventoryEntitiesUseSnowflakeIds() throws NoSuchFieldException { + for (Class type : new Class[] { + InventoryBalance.class, InventoryPosition.class, InventoryLedger.class, InventoryOperation.class + }) { + TableId tableId = type.getDeclaredField("id").getAnnotation(TableId.class); + assertNotNull(tableId, type.getSimpleName()); + assertEquals(IdType.ASSIGN_ID, tableId.type(), type.getSimpleName()); + } + } + + @Test + void incrementalMigrationIsAdditiveAndRerunnable() throws IOException { + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8) + .toLowerCase(); + assertTrue(migration.contains("2026-08-17 / 008")); + assertTrue(migration.contains("create table if not exists ll_inventory_balance")); + assertTrue(migration.contains("create table if not exists ll_inventory_position")); + assertTrue(migration.contains("create table if not exists ll_inventory_ledger")); + assertTrue(migration.contains("create table if not exists ll_inventory_operation")); + assertTrue(migration.contains("2026-08-17 / 015")); + assertTrue(migration.contains("add column inbound_order_id bigint null")); + assertTrue(migration.contains("quantity columns remain untouched")); + assertTrue(migration.contains("2026-08-17 / 018")); + assertTrue(migration.contains("alter table ll_order add column picked_package_count")); + assertTrue(migration.contains("alter table ll_order add column shipped_package_count")); + assertTrue(migration.contains("alter table ll_inventory_balance add column inbound_order_no")); + assertTrue(migration.contains("update ll_inventory_balance")); + assertTrue(migration.contains("where inbound_order_id is null or inbound_code is null or inbound_code = ''")); + assertTrue(migration.contains("alter table ll_inventory_ledger add column inbound_order_id")); + assertTrue(migration.contains("update ll_inventory_ledger")); + assertTrue(migration.contains("alter table ll_dispatch_order add column partial_completion_reason")); + assertFalse(migration.contains("drop column sku_code")); + assertFalse(migration.contains("drop column product_quantity")); + } + + @Test + void entitiesExposeInboundOrderPackageDimensionAndLegacyQuantityPlaceholders() throws NoSuchFieldException { + assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId")); + assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode")); + assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId")); + assertThrowsNoField(InventoryBalance.class, "skuCode"); + assertThrowsNoField(InventoryBalance.class, "availableQuantity"); + assertThrowsNoField(InventoryPosition.class, "skuCode"); + assertThrowsNoField(InventoryPosition.class, "productQuantity"); + assertNotNull(InventoryLedger.class.getDeclaredField("skuCode")); + assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark")); + assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity")); + assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity")); + assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity")); + } + + private static void assertThrowsNoField(Class type, String fieldName) { + assertTrue(java.util.Arrays.stream(type.getDeclaredFields()).noneMatch(field -> field.getName().equals(fieldName)), + () -> type.getSimpleName() + " must not expose " + fieldName); + } + + private static String tableDefinition(String schema, String tableName) { + int start = schema.indexOf("create table if not exists " + tableName); + int end = schema.indexOf("engine=innodb", start); + return schema.substring(start, end); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java new file mode 100644 index 000000000..b85001fd9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryOperation; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryOperationMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InventoryCommandServiceTest { + @Mock InventoryBalanceMapper balances; + @Mock InventoryPositionMapper positions; + @Mock InventoryLedgerMapper ledgers; + @Mock InventoryOperationMapper operations; + @Mock InventoryCommandMapper commands; + @Mock WarehouseResourceGateway warehouseGateway; + + private InventoryCommandService service; + + @BeforeEach + void setUp() { + service = new InventoryCommandService(); + ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "ledgers", ledgers); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + lenient().when(operations.findIdempotency(any(), any())).thenReturn(null); + } + + @Test + void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception { + assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class, + InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); + + assertEquals(12, result.availablePackages()); + assertFalse(result.replayed()); + ArgumentCaptor balance = ArgumentCaptor.forClass(InventoryBalance.class); + verify(balances).updateById(balance.capture()); + assertEquals(12L, balance.getValue().getAvailablePackages()); + verify(positions).insert(any(InventoryPosition.class)); + verify(ledgers).insert(any(InventoryLedger.class)); + verify(operations).updateById(any(InventoryOperation.class)); + } + + @Test + void stockInAllowsPalletPositionWithZeroOptionalPackages() { + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + + var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0)); + + assertEquals(0, result.availablePackages()); + verify(positions).insert(any(InventoryPosition.class)); + verify(ledgers).insert(any(InventoryLedger.class)); + } + + @Test + void holdRejectsWhenAvailablePackagesAreInsufficient() { + InventoryBalance balance = balance(200L, 5); + InventoryPosition position = position(201L, 200L, 5); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + + assertThrows(ResponseStatusException.class, + () -> service.hold("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 6, "ORD-1", null, null, "hold-1", null))); + verify(positions, never()).updateById(any(InventoryPosition.class)); + verify(balances, never()).updateById(any(InventoryBalance.class)); + } + + @Test + void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception { + var request = stockIn("IN-1", "stock-in-1", 12, 24); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + service.stockIn("100", "7", request); + ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); + verify(operations).insert(operation.capture()); + when(operations.findIdempotency("100", "stock-in-1")).thenReturn(operation.getValue()); + + InventoryCommandResponse replay = service.stockIn("100", "7", request); + + assertTrue(replay.replayed()); + verify(balances, times(1)).updateById(any(InventoryBalance.class)); + verify(ledgers, times(1)).insert(any(InventoryLedger.class)); + } + + @Test + void shippingLastPackagesScrapsPalletWithoutReleasingItsVirtualLocation() { + InventoryBalance balance = balance(200L, 0); + balance.setPickedPackages(5L); + InventoryPosition position = position(201L, 200L, 0); + position.setPickedPackages(5L); + when(commands.lockPosition("100", 201L)).thenReturn(position); + when(commands.lockBalance("100", 200L)).thenReturn(balance); + when(commands.palletPackages("100", 300L)).thenReturn(0L); + + var result = service.ship("100", "7", new InventoryCommandRequests.Move( + "100", 201L, 5, "OUT-1", "OUT-1", null, "ship-1", null)); + + assertTrue(result.palletEmpty()); + assertEquals("EMPTY", position.getStatus()); + verify(warehouseGateway).scrapPallet("100", 300L); + verify(ledgers).insert(any(InventoryLedger.class)); + } + + private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { + return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", + "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null, + packages, "ORD-1", "EVT-1", key, null); + } + + private InventoryBalance balance(Long id, long available) { + InventoryBalance value = new InventoryBalance(); + value.setId(id); value.setCustomerId("100"); value.setOverseasWarehouseId("WH-1"); + value.setDestinationWarehouseCode("ONT8"); value.setInboundOrderId(101L); + value.setInboundOrderNo("IN-1"); value.setInboundCode("IN-CODE-1"); + value.setAvailablePackages(available); + value.setHoldPackages(0L); value.setAllocatedPackages(0L); value.setPickedPackages(0L); + value.setVersion(0L); + return value; + } + + private InventoryPosition position(Long id, Long balanceId, long available) { + InventoryPosition value = new InventoryPosition(); + value.setId(id); value.setCustomerId("100"); value.setBalanceId(balanceId); + value.setPalletId(300L); value.setPalletNo("PLT-1"); value.setBatchNo("DEFAULT"); + value.setAvailablePackages(available); value.setHoldPackages(0L); + value.setAllocatedPackages(0L); value.setPickedPackages(0L); value.setReceivingPackages(0L); + value.setVersion(0L); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java new file mode 100644 index 000000000..3fa85c54a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.inventory.command.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +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.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 InventoryCommandControllerTest { + @Mock InventoryCommandService service; + private InventoryCommandController controller; + + @BeforeEach + void setUp() { + controller = new InventoryCommandController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void inventoryMutationRequiresFormPermission() { + var request = move(); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> controller.hold(user(Set.of()), request)); + assertEquals(403, exception.getStatusCode().value()); + verify(service, never()).hold(any(), any(), any()); + } + + @Test + void permittedBackOfficeUserCanRunInventoryFormCommand() { + var request = move(); + when(service.hold("100", "7", request)).thenReturn(response()); + controller.hold(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request); + verify(service).hold("100", "7", request); + } + + @Test + void physicalAdjustmentRequiresDedicatedPermission() { + var request = new InventoryCommandRequests.Adjustment("100", 1L, 2, "COUNT", "ORD-1", "adj-1", null); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> controller.adjust(user(Set.of(InventoryCommandController.MANAGE_PERMISSION)), request)); + assertEquals(403, exception.getStatusCode().value()); + verify(service, never()).adjust(any(), any(), any()); + } + + private InventoryCommandRequests.Move move() { + return new InventoryCommandRequests.Move("100", 1L, 2, "ORD-1", null, null, "hold-1", null); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 100L, 100L, 8L, "operator", "MERCHANT", "WEB", permissions); + } + + private InventoryCommandResponse response() { + return new InventoryCommandResponse(1L, "HOLD", 2L, 3L, 0, 2, 0, 0, false, false); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java new file mode 100644 index 000000000..451032574 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.inventory.query.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.service.InventoryQueryService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class InventoryQueryControllerTest { + private final InventoryQueryService service = mock(InventoryQueryService.class); + private final InventoryQueryController controller = controller(); + private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery( + 1, 20, null, null, null, null, null, null, null, null); + + @Test + void merchantCannotOverrideCustomerScope() { + controller.balances(user(100L, "MERCHANT"), "200", query); + verify(service).balances(eq("100"), eq(query)); + } + + @Test + void platformMustSelectCustomer() { + assertThrows(ResponseStatusException.class, + () -> controller.summary(user(0L, "PLATFORM"), null, query)); + } + + @Test + void platformCanQuerySelectedCustomer() { + controller.summary(user(0L, "PLATFORM"), "200", query); + verify(service).dashboard(eq("200"), eq(query)); + } + + @Test + void unauthenticatedPositionDetailIsRejected() { + InventoryQueryRequests.PositionQuery positionQuery = + new InventoryQueryRequests.PositionQuery(null, null, null); + assertThrows(ResponseStatusException.class, + () -> controller.positions(null, null, 1L, positionQuery)); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); + } + + private InventoryQueryController controller() { + InventoryQueryController controller = new InventoryQueryController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java new file mode 100644 index 000000000..1bbfa8ad6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -0,0 +1,131 @@ +package com.baiyue.lastleg.inventory.query.service; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.query.dto.InventoryDashboardResponse; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryQueryMapper; +import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class InventoryQueryServiceTest { + private final InventoryQueryMapper mapper = mock(InventoryQueryMapper.class); + private final InventoryQueryService service = new InventoryQueryService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test + void dashboardUsesSameTenantAndFiltersForTotalsAndPalletCount() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("AVAILABLE"); + InventoryAggregateProjection aggregate = new InventoryAggregateProjection(); + aggregate.setDimensionCount(2L); + aggregate.setAvailablePackages(12L); + when(mapper.aggregate("100", query)).thenReturn(aggregate); + when(mapper.countPallets("100", query)).thenReturn(3L); + + InventoryDashboardResponse result = service.dashboard("100", query); + + assertEquals(2, result.inboundOrderCount()); + assertEquals(3, result.palletCount()); + assertEquals(12, result.totalPackages()); + } + + @Test + void balancePageIsBoundToRequestedTenant() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(1L); + balance.setCustomerId("100"); + when(mapper.countBalances("100", query)).thenReturn(1L); + when(mapper.balances("100", query, 0, 20)).thenReturn(List.of(balance)); + + service.balances("100", query); + + verify(mapper).countBalances("100", query); + verify(mapper).balances("100", query, 0, 20); + } + + @Test + void unknownStatusIsRejectedBeforeDatabaseQuery() { + InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED"); + + assertThrows(ResponseStatusException.class, () -> service.balances("100", query)); + verify(mapper, never()).countBalances(any(), any()); + } + + @Test + void positionDetailDoesNotLeakAnotherTenantBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + assertThrows(ResponseStatusException.class, () -> service.positions("100", 99L, query)); + verify(mapper, never()).positions(eq("100"), eq(99L), any()); + } + + @Test + void positionDetailCarriesInboundOrderAndInboundCodeFromOwnedBalance() { + InventoryQueryRequests.PositionQuery query = new InventoryQueryRequests.PositionQuery(null, null, null); + InventoryBalance balance = new InventoryBalance(); + balance.setId(99L); + balance.setInboundOrderId(88L); + balance.setInboundOrderNo("IBP-20260817-001"); + balance.setInboundCode("RK-001"); + InventoryPosition position = new InventoryPosition(); + position.setBalanceId(99L); + position.setAvailablePackages(7L); + when(mapper.ownedBalance("100", 99L)).thenReturn(balance); + when(mapper.positions("100", 99L, query)).thenReturn(List.of(position)); + + var result = service.positions("100", 99L, query).get(0); + + assertEquals(88L, result.inboundOrderId()); + assertEquals("IBP-20260817-001", result.inboundOrderNo()); + assertEquals("RK-001", result.inboundCode()); + assertEquals(7L, result.totalPackages()); + } + + @Test + void reversedLedgerDateRangeIsRejected() { + InventoryQueryRequests.LedgerQuery query = new InventoryQueryRequests.LedgerQuery( + 1, 20, null, null, null, null, null, null, null, null, null, + java.time.LocalDateTime.of(2026, 8, 18, 0, 0), + java.time.LocalDateTime.of(2026, 8, 17, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.ledgers("100", query)); + verify(mapper, never()).countLedgers(any(), any()); + } + + @Test + void balanceExportUsesInboundOrderPackageColumnsOnly() { + InventoryQueryRequests.BalanceQuery query = balanceQuery(null); + when(mapper.balances("100", query, 0, 10_000)).thenReturn(List.of()); + + String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); + + assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages")); + assertFalse(csv.contains("skuCode")); + assertFalse(csv.contains("Quantity")); + } + + private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { + return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java new file mode 100644 index 000000000..0e2a97a8a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementInventoryBoundaryTest { + @Test + void procurementRemainsABusinessDocumentAndDoesNotMoveAvailableInventory() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java"), + StandardCharsets.UTF_8); + + assertTrue(source.contains("PENDING_REVIEW")); + assertFalse(source.contains("InventoryCommandService")); + assertFalse(source.contains("stockIn(")); + assertFalse(source.contains("setAvailablePackages")); + assertFalse(source.contains("availablePackages")); + } +} 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 new file mode 100644 index 000000000..d9d7cdba0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementSchemaContractTest { + @Test + void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history")); + assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)")); + } +} 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 new file mode 100644 index 000000000..61559b9ee --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.procurement.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.service.OverseasProcurementService; +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.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class OverseasProcurementControllerTest { + @Mock OverseasProcurementService service; + private MerchantOverseasProcurementController merchant; + private PlatformOverseasProcurementController platform; + + @BeforeEach + void setUp() { + merchant = new MerchantOverseasProcurementController(); + platform = new PlatformOverseasProcurementController(); + ReflectionTestUtils.setField(merchant, "service", service); + ReflectionTestUtils.setField(platform, "service", service); + } + + @Test + void merchantAlwaysUsesTenantFromTrustedIdentity() { + var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null); + merchant.page(user(100L, "MERCHANT", Set.of()), query); + verify(service).page("100", query); + } + + @Test + void reviewRequiresExplicitPermission() { + var request = new OverseasProcurementRequests.Review("APPROVE", null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> platform.review(user(0L, "PLATFORM", Set.of()), "100", "HC1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void merchantEndpointRejectsPlatformIdentity() { + var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> merchant.page(user(0L, "PLATFORM", Set.of()), query)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void platformReviewUsesExplicitTargetTenant() { + var request = new OverseasProcurementRequests.Review("APPROVE", "ok"); + platform.review(user(0L, "PLATFORM", Set.of(OverseasProcurementService.REVIEW_PERMISSION)), + "100", "HC1", request); + verify(service).review("100", "HC1", "7", request); + } + + private AuthenticatedUser user(long tenantId, String appCode, Set permissions) { + return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions); + } +} 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 new file mode 100644 index 000000000..308801744 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.procurement.service; + +import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +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 org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.when; + +@ExtendWith(MockitoExtension.class) +class OverseasProcurementServiceTest { + @Mock OverseasProcurementOrderMapper orders; + @Mock OverseasProcurementOutboundMapper outbounds; + @Mock OverseasProcurementStatusHistoryMapper histories; + private OverseasProcurementService service; + + @BeforeEach + void setUp() { + service = new OverseasProcurementService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "outbounds", outbounds); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + } + + @Test + void customerOrderAlwaysStartsPendingReviewWithZeroInventory() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(OverseasProcurementOrder.class))).thenAnswer(invocation -> { + saved.set(invocation.getArgument(0)); + return 1; + }); + when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get()); + when(outbounds.listOrder(anyString(), any())).thenReturn(List.of()); + when(histories.listOrder(anyString(), any())).thenReturn(List.of()); + + var detail = service.create("100", "7", new OverseasProcurementRequests.Create( + "Acme", "Office", "Labels", "100x150", "https://example.test/product", 500L, + new BigDecimal("26.50"), "USD", "Ningbo", "Buyer", "13800000000", null, "key-1")); + + assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); + assertEquals(0, detail.summary().inboundQuantity()); + assertEquals(0, detail.summary().inventoryQuantity()); + } + + @Test + void pendingOrderCannotReceiveInventory() { + OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); + when(orders.lockNo("100", "HC1")).thenReturn(order); + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + 100L, null, null, null, null, null, null, null, null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "HC1", request)); + assertEquals(409, error.getStatusCode().value()); + } + + @Test + void outboundCannotExceedDerivedInventory() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80); + when(orders.lockNo("100", "HC1")).thenReturn(order); + when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0); + var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null, + LocalDateTime.now(), null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.addOutbound("100", "HC1", "7", request)); + assertEquals(409, error.getStatusCode().value()); + } + + private OverseasProcurementOrder order(String status, long inbound, long outbound) { + OverseasProcurementOrder order = new OverseasProcurementOrder(); + order.setId(1L); + order.setCustomerId("100"); + order.setOrderNo("HC1"); + order.setStatus(status); + order.setInboundQuantity(inbound); + order.setOutboundQuantity(outbound); + order.setPurchaseQuantity(500L); + order.setVersion(0L); + order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(order.getCreatedAt()); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java new file mode 100644 index 000000000..e603bb626 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.relabel; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RelabelContractTest { + @Test + void createHeaderDoesNotExposeWaybillLabelFileAndItemCarriesRequestedFileFields() { + assertFalse(Arrays.stream(RelabelRequests.Create.class.getRecordComponents()) + .map(RecordComponent::getName) + .anyMatch(name -> name.toLowerCase().contains("waybill") || name.toLowerCase().contains("labelFile"))); + assertTrue(Arrays.stream(RelabelRequests.Item.class.getRecordComponents()) + .map(RecordComponent::getName).toList().containsAll(Arrays.asList("skuCode", "productImageFileId", + "productNameZh", "totalPackageCount", "notifiedPackageCount", "shipmentNo", "trackingCode", + "newSkuCode", "labelRequirement", "relabelFileId"))); + } + + @Test + void schemasDefineRelabelApplicationItemsAndHistoryContracts() throws Exception { + String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String schema : new String[] { baseline, migration }) { + assertTrue(schema.contains("ll_relabel_application")); + assertTrue(schema.contains("ll_relabel_item")); + assertTrue(schema.contains("ll_relabel_status_history")); + assertTrue(schema.contains("customer_id")); + assertTrue(schema.contains("idempotency_key")); + assertTrue(schema.contains("request_hash")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java new file mode 100644 index 000000000..97a8dad24 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -0,0 +1,259 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.dto.RelabelResponses; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RelabelApplicationServiceTest { + @Mock RelabelApplicationMapper applications; + @Mock RelabelItemMapper items; + @Mock RelabelStatusHistoryMapper histories; + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock LastlegOrderNoGenerator orderNoGenerator; + + private RelabelApplicationService service; + + @BeforeEach + void setUp() { + service = new RelabelApplicationService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "objectMapper", mapper()); + lenient().when(orderNoGenerator.next()).thenReturn("LMO-RELABEL-1"); + } + + @Test + void createBuildsPendingRelabelTailOrderItemsAndBothHistories() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication persisted = application(11L, "FBR-1", "TENANT-A", "key-1", null, + RelabelApplicationService.PENDING_PROCESSING); + persisted.setLastlegOrderId(21L); + persisted.setItemCount(1); persisted.setTotalPackageCount(10); persisted.setNotifiedPackageCount(6); + when(applications.findNo(eq("TENANT-A"), any())).thenReturn(persisted); + when(items.selectList(any())).thenReturn(List.of(item(31L, 11L, "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(21L)).thenReturn(order(21L, "TENANT-A", "LMO-RELABEL-1", + RelabelApplicationService.PENDING_PROCESSING)); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + ArgumentCaptor application = ArgumentCaptor.forClass(RelabelApplication.class); + ArgumentCaptor relabelItem = ArgumentCaptor.forClass(RelabelItem.class); + ArgumentCaptor relabelHistory = ArgumentCaptor.forClass(RelabelStatusHistory.class); + ArgumentCaptor tailHistory = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(orders).insert(tailOrder.capture()); + verify(applications).insert(application.capture()); + verify(items).insert(relabelItem.capture()); + verify(histories).insert(relabelHistory.capture()); + verify(orderHistories).insert(tailHistory.capture()); + + assertEquals("RELABEL_APPLICATION", tailOrder.getValue().getOrderType()); + assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("CUSTOMER", tailOrder.getValue().getSourceRegion()); + assertEquals("LMO-RELABEL-1", tailOrder.getValue().getOrderNo()); + assertEquals(tailOrder.getValue().getInboundCode(), tailOrder.getValue().getSourceInboundId()); + assertEquals(tailOrder.getValue().getInboundCode(), application.getValue().getRelabelNo()); + assertTrue(tailOrder.getValue().getInboundCode().startsWith("FBR")); + assertEquals(10, tailOrder.getValue().getPackageCount()); + assertEquals(6, application.getValue().getNotifiedPackageCount()); + assertEquals("SKU-A", relabelItem.getValue().getSkuCode()); + assertEquals("PENDING_PROCESSING", relabelHistory.getValue().getToStatus()); + assertEquals("PENDING_ASSIGNMENT", tailHistory.getValue().getToStatus()); + assertEquals("FBR-1", result.application().relabelNo()); + } + + @Test + void createRejectsNotifiedBoxesAboveTotalBeforeAnyWrite() { + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request("key-1", 5, 6))); + + assertEquals(400, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void sameIdempotencyKeyAndPayloadReturnsExistingApplicationWithoutWrites() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", hash(request), + RelabelApplicationService.PENDING_PROCESSING); + existing.setLastlegOrderId(21L); existing.setItemCount(1); existing.setTotalPackageCount(10); + existing.setNotifiedPackageCount(6); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + stubDetail(existing); + + RelabelResponses.Detail result = service.create("TENANT-A", "operator-1", request); + + assertEquals("FBR-1", result.application().relabelNo()); + verify(orders, never()).insert(any(LastlegOrder.class)); + verify(applications, never()).insert(any(RelabelApplication.class)); + verify(items, never()).insert(any(RelabelItem.class)); + } + + @Test + void idempotencyKeyCannotBeReusedWithDifferentRelabelPayload() { + RelabelRequests.Create request = request("key-1", 10, 6); + RelabelApplication existing = application(11L, "FBR-1", "TENANT-A", "key-1", "different-hash", + RelabelApplicationService.PENDING_PROCESSING); + when(applications.findKey("TENANT-A", "key-1")).thenReturn(existing); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.create("TENANT-A", "operator-1", request)); + + assertEquals(409, exception.getStatusCode().value()); + verify(orders, never()).insert(any(LastlegOrder.class)); + } + + @Test + void statusChangeIsTenantScopedAndWritesApplicationAndTailOrderHistory() { + RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", + RelabelApplicationService.PENDING_PROCESSING); + application.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + stubDetail(application); + + RelabelResponses.Detail result = service.changeStatus("TENANT-A", "FBR-1", "operator-2", + new RelabelRequests.ChangeStatus("processing", "started")); + + assertEquals("PROCESSING", application.getStatus()); + assertEquals("PROCESSING", result.application().status()); + verify(applications).updateById(application); + ArgumentCaptor updatedOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders).updateById(updatedOrder.capture()); + assertEquals("PENDING_ASSIGNMENT", updatedOrder.getValue().getCurrentStatus()); + assertEquals("RELABEL:PROCESSING", updatedOrder.getValue().getSubOrderStatus()); + verify(histories).insert(any(RelabelStatusHistory.class)); + verify(orderHistories).insert(any(LastlegOrderStatusHistory.class)); + } + + @Test + void completedApplicationCannotReturnToProcessingAndOtherTenantCannotReadIt() { + RelabelApplication completed = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "COMPLETED"); + completed.setLastlegOrderId(21L); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(completed); + + ResponseStatusException transition = assertThrows(ResponseStatusException.class, () -> + service.changeStatus("TENANT-A", "FBR-1", "operator-2", new RelabelRequests.ChangeStatus("PROCESSING", null))); + assertEquals(409, transition.getStatusCode().value()); + + when(applications.findNo("TENANT-B", "FBR-1")).thenReturn(null); + ResponseStatusException boundary = assertThrows(ResponseStatusException.class, () -> + service.detail("TENANT-B", "FBR-1")); + assertEquals(404, boundary.getStatusCode().value()); + verify(applications).findNo("TENANT-B", "FBR-1"); + } + + @Test + void completedReturnRelabelOrderEntersPendingAssignment() { + RelabelApplication application = application(11L, "FBR-1", "TENANT-A", "key-1", "hash", "PROCESSING"); + application.setLastlegOrderId(21L); + LastlegOrder order = order(21L, "TENANT-A", "LMO-RELABEL-1", "PROCESSING"); + order.setOrderType("RETURN_RELABEL"); + when(applications.lockNo("TENANT-A", "FBR-1")).thenReturn(application); + stubDetail(application); + when(orders.selectById(21L)).thenReturn(order); + + service.changeStatus("TENANT-A", "FBR-1", "operator-2", + new RelabelRequests.ChangeStatus("COMPLETED", "finished")); + + assertEquals("COMPLETED", application.getStatus()); + assertEquals("PENDING_ASSIGNMENT", order.getCurrentStatus()); + assertEquals("COMPLETED", order.getRelabelStatus()); + } + + private void stubDetail(RelabelApplication application) { + when(applications.findNo(application.getCustomerId(), application.getRelabelNo())).thenReturn(application); + when(items.selectList(any())).thenReturn(List.of(item(31L, application.getId(), "SKU-A", 10, 6))); + when(histories.selectList(any())).thenReturn(List.of()); + when(orders.selectById(application.getLastlegOrderId())).thenReturn(order(application.getLastlegOrderId(), + application.getCustomerId(), "LMO-RELABEL-1", application.getStatus())); + } + + private static RelabelRequests.Create request(String key, int total, int notified) { + return new RelabelRequests.Create("CUSTOMER-1", "WH-1", LocalDateTime.of(2026, 8, 20, 10, 0), + "Li", "13800000000", "remark", key, List.of(new RelabelRequests.Item("SKU-A", "img-1", + "image.png", "https://files/image.png", "Chinese name", total, notified, "SHP-1", "TRACK-1", + "SKU-NEW", "Label on carton", "file-1", "relabel.pdf", "https://files/relabel.pdf"))); + } + + private static RelabelApplication application(long id, String no, String customerId, String key, String requestHash, + String status) { + RelabelApplication value = new RelabelApplication(); + value.setId(id); value.setCustomerId(customerId); value.setRelabelNo(no); value.setIdempotencyKey(key); + value.setRequestHash(requestHash); value.setStatus(status); value.setCreatedAt(LocalDateTime.now()); + value.setContactName("Li"); value.setContactPhone("13800000000"); value.setCustomerOrderNo("CUSTOMER-1"); + value.setOverseasWarehouseId("WH-1"); value.setLabelDeadline(LocalDateTime.now().plusDays(1)); + value.setItemCount(1); value.setTotalPackageCount(10); value.setNotifiedPackageCount(6); + return value; + } + + private static RelabelItem item(long id, long applicationId, String sku, int total, int notified) { + RelabelItem value = new RelabelItem(); + value.setId(id); value.setApplicationId(applicationId); value.setCustomerId("TENANT-A"); value.setSkuCode(sku); + value.setProductNameZh("Chinese name"); value.setTotalPackageCount(total); value.setNotifiedPackageCount(notified); + value.setShipmentNo("SHP-1"); value.setNewSkuCode("SKU-NEW"); + return value; + } + + private static LastlegOrder order(long id, String customerId, String orderNo, String status) { + LastlegOrder value = new LastlegOrder(); + value.setId(id); value.setCustomerId(customerId); value.setOrderNo(orderNo); value.setCurrentStatus(status); + return value; + } + + private static String hash(RelabelRequests.Create request) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(mapper().writeValueAsString(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private static ObjectMapper mapper() { + return new ObjectMapper().findAndRegisterModules(); + } +} 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 new file mode 100644 index 000000000..bab9745c3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaChineseCommentContractTest { + + 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_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + + @Test + void allLastlegTablesAndColumnsHaveChineseComments() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55); + assertAllChineseComments(incremental, "增量 SQL", 49); + assertCommentMigrationCovers(incremental, baselineTables); + } + + @Test + void newlyAddedColumnsCarryChineseCommentsInBaselineAndIncrementalSql() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + 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" + ); + for (String column : columns) { + assertColumnHasChineseComment(baseline, column, "基线 SQL"); + assertColumnHasChineseComment(incremental, column, "增量 SQL"); + assertIncrementalAddColumnHasChineseComment(incremental, column); + } + } + + private static List assertAllChineseComments(String sql, String source, int expectedCount) { + List tables = new ArrayList<>(); + Matcher matcher = TABLE.matcher(sql); + while (matcher.find()) { + String table = matcher.group(1); + assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table); + List columns = splitTopLevel(matcher.group(2)); + assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table); + List columnNames = new ArrayList<>(); + for (String definition : columns) { + if (isConstraint(definition)) { + continue; + } + String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", ""); + columnNames.add(column); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段备注必须包含中文: " + table + "." + column); + } + tables.add(new TableDefinition(table, columnNames)); + } + assertTrue(tables.size() >= expectedCount, + source + " 解析到的尾程表数量不足,实际=" + tables.size() + ",期望至少=" + expectedCount); + return tables; + } + + private static void assertCommentMigrationCovers(String incremental, List tables) { + int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); + assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移"); + String migration = incremental.substring(marker); + for (TableDefinition table : tables) { + assertTrue(migration.contains("table_name='" + table.name() + "'"), + "025 备注迁移缺少表: " + table.name()); + for (String column : table.columns()) { + assertTrue(migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"), + "025 备注迁移缺少字段: " + table.name() + "." + column); + } + } + } + + private static boolean isConstraint(String definition) { + String normalized = definition.stripLeading().toUpperCase(); + return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ") + || normalized.startsWith("KEY ") || normalized.startsWith("CONSTRAINT ") + || normalized.startsWith("FOREIGN ") || normalized.startsWith("CHECK "); + } + + private static void assertColumnHasChineseComment(String sql, String column, String source) { + Pattern columnPattern = Pattern.compile( + "(?is)\\b" + Pattern.quote(column) + "\\b[^,;]*?COMMENT\\s+''?[^',;]*[\\u4e00-\\u9fff][^',;]*''?"); + assertTrue(columnPattern.matcher(sql).find(), source + " 新增字段缺少中文备注: " + column); + } + + private static void assertIncrementalAddColumnHasChineseComment(String sql, String column) { + Pattern addColumnPattern = Pattern.compile( + "(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column) + + "\\b[^;]*?COMMENT\\s+''[^';]*[\\u4e00-\\u9fff][^';]*''"); + assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column); + } + + private static List splitTopLevel(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int parentheses = 0; + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char current = body.charAt(i); + if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && current == '(') { + parentheses++; + } else if (!quoted && current == ')') { + parentheses--; + } else if (!quoted && parentheses == 0 && current == ',') { + definitions.add(body.substring(start, i).trim()); + start = i + 1; + } + } + definitions.add(body.substring(start).trim()); + return definitions.stream().filter(value -> !value.isEmpty()).toList(); + } + + private record TableDefinition(String name, List columns) { + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..49b0a41a3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.security; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +class InternalServiceTokenGuardTest { + @Test void acceptsOnlyConfiguredToken() { + InternalServiceTokenGuard guard=new InternalServiceTokenGuard("secret-token"); + assertDoesNotThrow(()->guard.verify("secret-token")); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); + assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java new file mode 100644 index 000000000..410b21505 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.service; +import org.junit.jupiter.api.Test; +import java.util.Set; +import java.util.concurrent.*; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; +class LastlegOrderNoGeneratorTest { + @Test void actualContainerNumberIsStableAndShared() { + LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); + assertEquals("LMO2087379674555461633", generator.forContainerNo("2087379674555461633")); + assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); + assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); + assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); + assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); + } + + @Test void remainsUniqueAcrossInstancesAndConcurrentCalls() throws Exception { + LastlegOrderNoGenerator beforeRestart=new LastlegOrderNoGenerator(); + String first=beforeRestart.next(); + LastlegOrderNoGenerator afterRestart=new LastlegOrderNoGenerator(); + assertNotEquals(first,afterRestart.next()); + ExecutorService pool=Executors.newFixedThreadPool(8); + try { + var tasks=IntStream.range(0,1000).mapToObj(i->(Callable)afterRestart::next).toList(); + Set ids=pool.invokeAll(tasks).stream().map(future->{ + try { return future.get(); } catch(Exception exception) { throw new RuntimeException(exception); } + }).collect(java.util.stream.Collectors.toSet()); + assertEquals(1000,ids.size()); + } finally { pool.shutdownNow(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java new file mode 100644 index 000000000..b2eae4f27 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.dropship.entity.DropshipCarton; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; +import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +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.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LastlegOrderServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper details; + @Mock LastlegOrderStatusHistoryMapper histories; + @Mock DropshipOrderProfileMapper dropshipProfiles; + @Mock DropshipSkuInventoryMapper dropshipSkuInventory; + @Mock DropshipCartonMapper dropshipCartons; + LastlegOrderService service; + + @BeforeEach + void setUp() { + service = new LastlegOrderService(); + ReflectionTestUtils.setField(service, "orderMapper", orders); + ReflectionTestUtils.setField(service, "detailMapper", details); + ReflectionTestUtils.setField(service, "historyMapper", histories); + ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); + ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); + ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); + } + + @Test + void detailGroupsDropshipSkuAndCartonsByPallet() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectOne(any())).thenReturn(order); + when(details.selectList(any())).thenReturn(List.of()); + when(histories.selectList(any())).thenReturn(List.of()); + DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1"); + DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2"); + when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second)); + DropshipSkuInventory sku = new DropshipSkuInventory(); + sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L); + when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku)); + DropshipCarton carton = new DropshipCarton(); + carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1"); + when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton)); + + var response = service.detail("100", 11L); + + assertEquals(2, response.dropshipPallets().size()); + assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo()); + assertEquals(1, response.dropshipPallets().get(0).skuInventory().size()); + assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark()); + assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty()); + } + + @Test + void transferUpdatesOrderAndEveryCargoSnapshot() { + LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertEquals(1, service.transferDestination("100", request)); + + assertEquals("wh-2", order.getTransferWarehouseId()); + assertEquals("LAX-02", order.getTransferWarehouseCode()); + assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus()); + ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details).update(cargo.capture(), any()); + assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); + ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); + verify(histories).insert(history.capture()); + assertEquals("PENDING_ASSIGNMENT", history.getValue().getFromStatus()); + assertEquals("DESTINATION_ASSIGNED", history.getValue().getToStatus()); + } + + @Test + void transferRejectsAnOrderAlreadyBoundToInboundPlan() { + LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + + assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); + verify(orders, never()).updateById(any(LastlegOrder.class)); + verify(details, never()).update(any(), any()); + } + + @Test + void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() { + when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null))); + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.transferDestination("100", request)); + assertTrue(error.getStatusCode().is4xxClientError()); + verify(orders, never()).updateById(any(LastlegOrder.class)); + } + + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { + LastlegOrder order = new LastlegOrder(); + order.setId(id); + order.setCustomerId(customerId); + order.setCurrentStatus(status); + order.setInboundOrderNo(inboundOrderNo); + return order; + } + + private static DropshipOrderProfile profile(long id, String sourcePalletNo, + long warehousePalletId, String palletNo) { + DropshipOrderProfile profile = new DropshipOrderProfile(); + profile.setId(id); profile.setSourcePalletNo(sourcePalletNo); + profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo); + profile.setStatus("RECEIVED"); + return profile; + } +} 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 new file mode 100644 index 000000000..30aa475d0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -0,0 +1,13 @@ +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.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 LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); 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)); } + @Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } + @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java new file mode 100644 index 000000000..7dcc994a7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.transfer; + +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehouseTransferSchemaContractTest { + @Test + void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception { + String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase(); + String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase(); + for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", + "ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation", + "ll_warehouse_transfer_receipt"}) { + assertTrue(baseline.contains("create table if not exists " + table)); + assertTrue(migration.contains("create table if not exists " + table)); + } + assertTrue(migration.contains("information_schema.statistics")); + assertTrue(migration.contains("uk_ll_wh_transfer_source")); + assertTrue(migration.contains("uk_ll_wh_transfer_active_position")); + assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java new file mode 100644 index 000000000..58ee4bcef --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -0,0 +1,371 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder; +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.math.BigDecimal; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseTransferServiceTest { + @Mock WarehouseTransferOrderMapper transfers; + @Mock WarehouseTransferItemMapper transferItems; + @Mock WarehouseTransferSourceOrderMapper sourceOrders; + @Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations; + @Mock WarehouseTransferReceiptMapper transferReceipts; + @Mock InboundSnapshotMapper inboundSnapshots; + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; + @Mock LastlegOrderStatusHistoryMapper orderHistories; + @Mock RelabelApplicationMapper relabelApplications; + @Mock RelabelItemMapper relabelItems; + @Mock RelabelStatusHistoryMapper relabelHistories; + @Mock LastlegOrderNoGenerator orderNoGenerator; + @Mock InventoryCommandService inventory; + @Mock InventoryCommandMapper inventoryCommands; + private WarehouseTransferService service; + + @BeforeEach + void setUp() { + service = new WarehouseTransferService(); + ReflectionTestUtils.setField(service, "transfers", transfers); + ReflectionTestUtils.setField(service, "transferItems", transferItems); + ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); + ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations); + ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts); + ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); + ReflectionTestUtils.setField(service, "orderHistories", orderHistories); + ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); + ReflectionTestUtils.setField(service, "relabelItems", relabelItems); + ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); + ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1"); + lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); + lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); + } + + @Test + void relabelTransferLinksHistoricalOrderAndInboundAndCreatesOnePendingReviewTailOrder() { + WarehouseTransferRequests.Create request = request(true); + LastlegOrder historical = historicalOrder(); + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historical)); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.create("TENANT-A", "USER-1", request); + + ArgumentCaptor tailOrder = ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders).insert(tailOrder.capture()); + assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType()); + assertEquals("PENDING_ASSIGNMENT", tailOrder.getValue().getCurrentStatus()); + assertEquals("WAREHOUSE_TRANSFER:PENDING_REVIEW;RELABEL:PENDING_PROCESSING", tailOrder.getValue().getSubOrderStatus()); + assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo()); + assertNull(tailOrder.getValue().getTransferWarehouseId()); + verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class)); + verify(relabelApplications).insert(any(RelabelApplication.class)); + verify(relabelItems).insert(any(RelabelItem.class)); + verify(relabelHistories).insert(any(RelabelStatusHistory.class)); + verify(orders).updateById(tailOrder.getValue()); + assertNotNull(tailOrder.getValue().getRelabelOrderNo()); + assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); + } + + @Test + void transferWithoutRelabelDoesNotCreateRelabelDocument() { + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of(historicalOrder())); + when(transfers.findNo(eq("TENANT-A"), org.mockito.ArgumentMatchers.anyString())).thenAnswer(invocation -> persistedTransfer()); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.create("TENANT-A", "USER-1", request(false)); + + verify(relabelApplications, never()).insert(any(RelabelApplication.class)); + verify(relabelItems, never()).insert(any(RelabelItem.class)); + verify(relabelHistories, never()).insert(any(RelabelStatusHistory.class)); + assertNull(result.relabelNo()); + } + + @Test + void approvedTransferWithoutRelabelEntersPendingAssignment() { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); + transfer.setNeedRelabel(false); + LastlegOrder tailOrder = historicalOrder(); + tailOrder.setId(31L); tailOrder.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + var result = service.review("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Review("APPROVE", "approved")); + + assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); + assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); + verify(transfers).updateById(transfer); + verify(orders).updateById(tailOrder); + verifyNoInteractions(inventory); + } + + @Test + void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() { + WarehouseTransferOrder transfer = transfer("APPROVED"); + LastlegOrder tailOrder = tailOrder(); + WarehouseTransferSourceOrder source = sourceOrder(); + InventoryPosition position = position(501L, 601L, 701L, 5L); + InventoryBalance balance = balance(601L, 101L, "IN-1"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(sourceOrders.selectList(any())).thenReturn(List.of(source)); + when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + + var result = service.allocate("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Allocate("ALLOC-1", + List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))); + + ArgumentCaptor captured = + ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class); + verify(inventoryAllocations).insert(captured.capture()); + assertEquals(101L, captured.getValue().getInboundOrderId()); + assertEquals(701L, captured.getValue().getPalletId()); + assertEquals(5, captured.getValue().getPackageCount()); + assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); + assertEquals("WAIT_OUTBOUND", result.orderStatus()); + verifyNoInteractions(inventory); + } + + @Test + void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + LastlegOrder tailOrder = tailOrder(); + WarehouseTransferInventoryAllocation allocation = allocation(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); + when(orders.selectById(31L)).thenReturn(tailOrder); + when(transferItems.selectList(any())).thenReturn(List.of()); + when(sourceOrders.selectList(any())).thenReturn(List.of()); + + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", "departed")); + + var ordered = inOrder(inventory); + ArgumentCaptor move = + ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class); + ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture()); + ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture()); + ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture()); + assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5)); + assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus()); + assertEquals("SHIPPED", allocation.getStatus()); + + clearInvocations(inventory); + service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", "departed")); + verifyNoInteractions(inventory); + + when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null); + when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn( + new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false)); + service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + + ArgumentCaptor stockIn = + ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); + verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture()); + assertEquals(5, stockIn.getValue().packages()); + assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); + assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); + assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); + assertEquals("RECEIVED", allocation.getStatus()); + + ArgumentCaptor receipt = + ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class); + verify(transferReceipts).insert(receipt.capture()); + when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue()); + clearInvocations(inventory); + service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest()); + verifyNoInteractions(inventory); + } + + @Test + void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() { + WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED); + WarehouseTransferInventoryAllocation allocation = allocation(); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); + doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient")) + .when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any()); + + assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1", + new WarehouseTransferRequests.Outbound("OUT-1", null))); + + verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any()); + verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class)); + verify(transfers, never()).updateById(transfer); + assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus()); + assertEquals("RESERVED", allocation.getStatus()); + } + + @Test + void allocationRejectsPositionOutsideSelectedInboundOrder() { + WarehouseTransferOrder transfer = transfer("APPROVED"); + when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); + when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder())); + when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L)); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> + service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1", + List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))))); + + assertEquals(409, exception.getStatusCode().value()); + verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class)); + verifyNoInteractions(inventory); + } + + @Test + void selectedOrderMustBelongToTenantAndHaveInboundBinding() { + when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L); + when(orders.selectList(any())).thenReturn(List.of()); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.create("TENANT-A", "USER-1", request(false))); + + assertEquals(404, exception.getStatusCode().value()); + verify(transfers, never()).insert(any(WarehouseTransferOrder.class)); + verify(orders, never()).insert(any(LastlegOrder.class)); + } + + private static WarehouseTransferRequests.Create request(boolean needRelabel) { + return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8", + "123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1", + "TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1", + List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1"))); + } + + private static LastlegOrder historicalOrder() { + LastlegOrder order = new LastlegOrder(); + order.setId(11L); order.setCustomerId("TENANT-A"); order.setOrderNo("LMO-OLD-1"); order.setInboundCode("IN-1"); + return order; + } + + private static WarehouseTransferOrder persistedTransfer() { + WarehouseTransferOrder transfer = new WarehouseTransferOrder(); + transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1"); + transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); + transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5); + return transfer; + } + + private static WarehouseTransferOrder transfer(String status) { + WarehouseTransferOrder transfer = persistedTransfer(); + transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5); + return transfer; + } + + private static LastlegOrder tailOrder() { + LastlegOrder order = historicalOrder(); + order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT"); + return order; + } + + private static WarehouseTransferSourceOrder sourceOrder() { + WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder(); + source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A"); + source.setInboundOrderId(101L); source.setInboundCode("IN-1"); + return source; + } + + private static InventoryPosition position(long id, long balanceId, long palletId, long available) { + InventoryPosition position = new InventoryPosition(); + position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId); + position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available); + return position; + } + + private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { + InventoryBalance balance = new InventoryBalance(); + balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); + balance.setInboundCode(inboundCode); + return balance; + } + + private static WarehouseTransferInventoryAllocation allocation() { + WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation(); + allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A"); + allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L); + allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L); + allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED"); + return allocation; + } + + private static WarehouseTransferRequests.Receipt receiptRequest() { + return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse", + "FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L, + "LOC-DEST", "RECEIVE-1", "received"); + } +} 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 new file mode 100644 index 000000000..724c2010e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.trucking; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UsTruckOrderSchemaContractTest { + @Test + void baselineContainsOrderAndImmutableHistoryTables() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order")); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history")); + 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("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 new file mode 100644 index 000000000..e70f33db3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.trucking.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +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.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class UsTruckOrderControllerTest { + @Mock UsTruckOrderService service; + private MerchantUsTruckOrderController merchant; + private PlatformUsTruckOrderController platform; + + @BeforeEach + void setUp() { + merchant = new MerchantUsTruckOrderController(); platform = new PlatformUsTruckOrderController(); + ReflectionTestUtils.setField(merchant, "service", service); ReflectionTestUtils.setField(platform, "service", service); + } + + @Test + void merchantListAlwaysUsesTrustedTenant() { + var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query); + verify(service).page("100", query); + } + + @Test + void platformStatusChangeRequiresManagePermission() { + var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> platform.changeStatus(user(0L, "PLATFORM", Set.of()), "100", "TK1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + @Test + void platformStatusChangeUsesExplicitTargetTenant() { + var request = new UsTruckOrderRequests.ChangeStatus(0L, "DISPATCHED", "truck assigned"); + platform.changeStatus(user(0L, "PLATFORM", Set.of(UsTruckOrderService.MANAGE_PERMISSION)), "100", "TK1", request); + verify(service).changeStatus("100", "TK1", "7", request); + } + + private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); } + private AuthenticatedUser user(long tenant, String app, Set permissions) { + return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions); + } +} 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 new file mode 100644 index 000000000..a26e52316 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -0,0 +1,142 @@ +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.UsTruckOrderPallet; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; +import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +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 org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.when; + +@ExtendWith(MockitoExtension.class) +class UsTruckOrderServiceTest { + @Mock UsTruckOrderMapper orders; + @Mock UsTruckOrderPalletMapper pallets; + @Mock UsTruckOrderStatusHistoryMapper histories; + private UsTruckOrderService service; + + @BeforeEach + void setUp() { + service = new UsTruckOrderService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "pallets", pallets); + ReflectionTestUtils.setField(service, "histories", histories); + 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 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; + }); + + 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)); + } + + 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); + } + + 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; + } +} diff --git a/pom.xml b/pom.xml index 9da4d3cd5..35b79ff33 100644 --- a/pom.xml +++ b/pom.xml @@ -38,11 +38,14 @@ common-audit common-event-contract gateway + gateway-overseas auth iam order firstleg warehouse + warehouse-overseas-service + lastleg-service file print audit-service diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql new file mode 100644 index 000000000..ca5541703 --- /dev/null +++ b/scripts/db/incremental.sql @@ -0,0 +1,3553 @@ +-- Baiyue last-leg platform incremental database archive +-- Encoding: UTF-8 without BOM +-- Policy: +-- 1. Append new numbered sections; never rewrite an applied section. +-- 2. DDL must be repeatable and must preserve existing business rows. +-- 3. Every new column and index must use an information_schema guard. +-- 4. Do not place test fixtures, DELETE, TRUNCATE or business-data UPDATE here. + +-- ============================================================================ +-- 2026-08-15 / 001 +-- Event Bridge inbox and delivery-attempt baseline +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) +) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id, attempt_no), + KEY idx_bridge_attempt_event (event_id, started_at) +) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录'; + +-- Upgrade an existing bridge_inbox without replacing payload JSON or other rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_owner'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_owner VARCHAR(128) NULL COMMENT ''消费租约持有者'' AFTER status')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 002 +-- Warehouse locations, pallets, resource locks and UTF-8 descriptions +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_warehouse_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_warehouse_overseas; + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (tenant_id,warehouse_id,location_code,deleted), + KEY idx_location_query (tenant_id,warehouse_id,zone_code,status,deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) NULL COMMENT '备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (tenant_id,warehouse_id,pallet_code,deleted), + KEY idx_pallet_query (tenant_id,warehouse_id,status,deleted), + KEY idx_pallet_location (tenant_id,warehouse_id,location_id,deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_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_resource_lock_reservation (tenant_id,reservation_key), + KEY idx_resource_lock_pallet (tenant_id,warehouse_id,pallet_id,status), + KEY idx_resource_lock_location (tenant_id,warehouse_id,location_id,status), + KEY idx_resource_lock_business (tenant_id,business_type,business_id) +) ENGINE=InnoDB COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; + +-- Fix legacy mojibake/English descriptions without changing row values. +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_resource_lock COMMENT='仓库托盘与库位资源锁表:支持跨服务幂等预留和释放'; +ALTER TABLE wh_resource_lock + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '资源锁ID(雪花ID)', + MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的托盘ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT '锁定的库位ID', + MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT '调用方业务类型', + MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT '调用方业务标识', + MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT '调用方资源预留幂等键', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态:ACTIVE有效、RELEASED释放、SCRAPPED报废', + MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + MODIFY COLUMN released_at DATETIME(3) NULL COMMENT '释放或报废时间', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +-- Guard every operational index for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='uk_location_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_location_code ON wh_location(tenant_id,warehouse_id,location_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'SELECT 1', 'CREATE INDEX idx_location_query ON wh_location(tenant_id,warehouse_id,zone_code,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='uk_pallet_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_pallet_code ON wh_pallet(tenant_id,warehouse_id,pallet_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_query'), 'SELECT 1', 'CREATE INDEX idx_pallet_query ON wh_pallet(tenant_id,warehouse_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND index_name='idx_pallet_location'), 'SELECT 1', 'CREATE INDEX idx_pallet_location ON wh_pallet(tenant_id,warehouse_id,location_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='uk_resource_lock_reservation'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_resource_lock_reservation ON wh_resource_lock(tenant_id,reservation_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_pallet'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_pallet ON wh_resource_lock(tenant_id,warehouse_id,pallet_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_location'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_location ON wh_resource_lock(tenant_id,warehouse_id,location_id,status)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND index_name='idx_resource_lock_business'), 'SELECT 1', 'CREATE INDEX idx_resource_lock_business ON wh_resource_lock(tenant_id,business_type,business_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 003 +-- Last-leg inbound plan, pallet allocation and PDA workflow +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan ( + id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', + plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态', + line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', + total_volume_cbm DECIMAL(18,4) 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_plan_no (plan_no), + UNIQUE KEY uk_ll_inbound_plan_container (tenant_id,container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( + id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '头程入仓码快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '入仓码体积(立方米)', + planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT '该明细分配的托盘数量', + required_package_count INT NOT NULL DEFAULT 0 COMMENT '计划应收箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_line_order (plan_id,order_id), + KEY idx_ll_inbound_line_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( + id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + pallet_no VARCHAR(64) NOT NULL COMMENT '计划托盘编号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + warehouse_pallet_id BIGINT NULL COMMENT '仓库托盘ID', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_resource_lock_id BIGINT NULL COMMENT '仓库资源锁ID', + assignment_status VARCHAR(32) NOT NULL DEFAULT 'UNASSIGNED' COMMENT '仓库资源分配状态', + locked_at DATETIME(3) NULL COMMENT '资源锁定时间', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际入托体积(立方米)', + actual_package_count INT NOT NULL DEFAULT 0 COMMENT '实际入托箱数', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no), + UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id,sequence_no), + KEY idx_ll_inbound_pallet_plan (plan_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; + +CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( + id BIGINT NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', + line_id BIGINT NOT NULL COMMENT '入库计划明细ID', + pallet_id BIGINT NOT NULL COMMENT '计划托盘ID', + allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '计划分配体积(立方米)', + planned_package_count INT NOT NULL DEFAULT 0 COMMENT '计划分配箱数', + counted_package_count INT NOT NULL DEFAULT 0 COMMENT '清点箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT '上架箱数', + actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '实际分配体积(立方米)', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_COUNT' COMMENT 'PDA作业状态', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inbound_allocation (line_id,pallet_id), + KEY idx_ll_inbound_allocation_plan (plan_id), + KEY idx_ll_inbound_allocation_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; + +-- Columns introduced after the initial inbound-plan release. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划应收箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库托盘ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''仓库资源锁ID''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''仓库资源分配状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际入托体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实际入托箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''上架箱数''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实际分配体积(立方米)''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''PDA作业状态''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( + id BIGINT NOT NULL COMMENT 'PDA任务ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入库计划ID', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(255) NULL COMMENT '操作人名称', pushed_at DATETIME(3) NULL COMMENT '推送时间', + started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (tenant_id,plan_id), + UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA任务表'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( + id BIGINT NOT NULL COMMENT 'PDA操作记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '托盘分配记录ID', + action VARCHAR(32) NOT NULL COMMENT '操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '请求摘要', request_json LONGTEXT NOT NULL COMMENT '请求JSON', + result_json LONGTEXT NOT NULL COMMENT '结果JSON', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inbound_pda_operation_task (task_id), KEY idx_ll_inbound_pda_operation_allocation (allocation_id) +) ENGINE=InnoDB COMMENT='尾程入库PDA幂等操作记录'; + +CREATE TABLE IF NOT EXISTS ll_inbound_pallet_merge ( + id BIGINT NOT NULL COMMENT '合托记录ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + task_id BIGINT NOT NULL COMMENT 'PDA任务ID', merge_group_no VARCHAR(64) NOT NULL COMMENT '合托组号', + source_pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', target_pallet_id BIGINT NOT NULL COMMENT '目标托盘ID', + source_allocation_id BIGINT NOT NULL COMMENT '来源分配ID', target_allocation_id BIGINT NOT NULL COMMENT '目标分配ID', + source_line_id BIGINT NOT NULL COMMENT '来源计划明细ID', target_line_id BIGINT NOT NULL COMMENT '目标计划明细ID', + source_order_id BIGINT NOT NULL COMMENT '来源订单ID', target_order_id BIGINT NOT NULL COMMENT '目标订单ID', + moved_package_count INT NOT NULL COMMENT '移动箱数', moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '移动体积(立方米)', + source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT '来源托盘是否报废', status VARCHAR(32) NOT NULL COMMENT '合托状态', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_inbound_merge_task (tenant_id,task_id), KEY idx_ll_inbound_merge_group (merge_group_no) +) ENGINE=InnoDB COMMENT='尾程入库PDA合托记录'; + +-- ============================================================================ +-- 2026-08-15 / 004 +-- Upgrade an existing Event Bridge inbox with retry/lease fields and indexes +-- ============================================================================ +USE baiyue_event_bridge; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='lease_until'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN lease_until DATETIME(3) NULL COMMENT ''消费租约截止时间'' AFTER lease_owner')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='retry_count'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''消费重试次数'' AFTER lease_until')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='next_retry_at'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次消费时间'' AFTER retry_count')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND column_name='last_error'), 'SELECT 1', 'ALTER TABLE bridge_inbox ADD COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后错误'' AFTER next_retry_at')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='idx_bridge_inbox_consume'), 'SELECT 1', 'CREATE INDEX idx_bridge_inbox_consume ON bridge_inbox(status,next_retry_at,lease_until)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='bridge_inbox' AND index_name='uk_bridge_inbox_idempotency'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_bridge_inbox_idempotency ON bridge_inbox(idempotency_key)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 005 +-- FirstLegLoadingPlanCompleted -> last-leg order projection +-- ============================================================================ + +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局唯一标识', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化事件负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '事件冻结负载JSON', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + last_error VARCHAR(1000) NULL COMMENT '最近一次处理错误', + PRIMARY KEY (event_id), + UNIQUE KEY uk_ll_loading_inbox_tenant_key (tenant_id,event_type,event_id) +) ENGINE=InnoDB COMMENT='头程装柜完成事件接收幂等箱'; + +CREATE TABLE IF NOT EXISTS ll_order ( + id BIGINT NOT NULL COMMENT '雪花ID', + order_no VARCHAR(32) NOT NULL COMMENT '尾程单号,同柜入仓单共享', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + order_type VARCHAR(32) NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓码', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域/系统', + source_inbound_id VARCHAR(128) NOT NULL COMMENT '最终实装LOADED分片ID,建单幂等键', + root_inbound_id VARCHAR(128) NULL COMMENT '根入仓单ID', + source_order_id VARCHAR(128) NULL COMMENT '来源订单ID', + source_order_no VARCHAR(128) NULL COMMENT '来源订单号', + root_order_id VARCHAR(128) NULL COMMENT '根订单ID', + customer_name VARCHAR(255) NULL COMMENT '货主名称', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称', + transfer_warehouse_id VARCHAR(128) NULL COMMENT '转入目的仓ID', + transfer_warehouse_code VARCHAR(128) NULL COMMENT '转入目的仓编码', + transfer_warehouse_name VARCHAR(255) NULL COMMENT '转入目的仓名称', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + container_no VARCHAR(64) NULL COMMENT '实际集装箱号', + container_status VARCHAR(32) NULL DEFAULT '待转柜' COMMENT '货柜状态', + abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', + detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + product_quantity INT NULL COMMENT '货物数量', + volume_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到港时间', + actual_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', + estimated_warehouse_at DATETIME(3) NULL COMMENT '预计到仓时间', + inbound_order_no VARCHAR(128) NULL COMMENT '入库单号', + outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + delivery_order_no VARCHAR(128) NULL COMMENT '派送单号', + relabel_order_no VARCHAR(128) NULL COMMENT '换标单号', + relabel_status VARCHAR(32) NULL COMMENT '换标状态', + pallet_merge_flag VARCHAR(32) NULL COMMENT '合托标记', + current_status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + private_remark VARCHAR(1000) NULL COMMENT '备注(仅自己)', + customer_remark VARCHAR(1000) NULL COMMENT '备注(客户)', + warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', + source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件ID', + source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划ID', + source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', + source_handover_version BIGINT NULL COMMENT '交接版本', + source_payload_hash VARCHAR(71) NOT 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_order_source (tenant_id,source_region,source_inbound_id), + KEY idx_ll_order_group (tenant_id,order_no), + KEY idx_ll_order_list (tenant_id,current_status,created_at) +) ENGINE=InnoDB COMMENT='尾程订单主表'; + +CREATE TABLE IF NOT EXISTS ll_order_detail ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入仓明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名', + product_name_en VARCHAR(255) NULL COMMENT '英文品名', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + product_quantity INT NULL COMMENT '数量', + pallet_count INT NULL COMMENT '托数', + gross_weight_kg DECIMAL(18,4) NULL COMMENT '毛重(千克)', + net_weight_kg DECIMAL(18,4) NULL COMMENT '净重(千克)', + dimensional_weight_kg DECIMAL(18,4) NULL COMMENT '体积重量(千克)', + volume_cbm DECIMAL(18,4) NULL COMMENT '体积(立方米)', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称', + fba_tracking_no VARCHAR(255) NULL COMMENT 'FBA跟踪号', + shipment_no VARCHAR(255) NULL COMMENT '货件编号', + shipping_mark VARCHAR(255) NULL COMMENT '唛头', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_detail_source (order_id,source_inbound_detail_id), + KEY idx_ll_detail_order (order_id) +) ENGINE=InnoDB COMMENT='尾程订单实装商品冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_loading_order_status_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', + event_id VARCHAR(128) NULL COMMENT '触发事件ID', + remark VARCHAR(500) NULL COMMENT '状态变更备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + PRIMARY KEY (id), + KEY idx_ll_loading_history_order (order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单状态变更历史'; + +-- Existing order installations: add transfer fields without replacing row data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_id VARCHAR(128) NULL COMMENT ''转入目的仓ID'' AFTER overseas_warehouse_name')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_code VARCHAR(128) NULL COMMENT ''转入目的仓编码'' AFTER transfer_warehouse_id')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN transfer_warehouse_name VARCHAR(255) NULL COMMENT ''转入目的仓名称'' AFTER transfer_warehouse_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- One container/loading-plan number groups multiple inbound-code rows, so order_no +-- must not be unique. Source inbound identity remains uniquely constrained. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_no' AND non_unique=0), 'ALTER TABLE ll_order DROP INDEX uk_ll_order_no', 'SELECT 1')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_group'), 'SELECT 1', 'CREATE INDEX idx_ll_order_group ON ll_order(tenant_id,order_no)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='uk_ll_order_source'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_order_source ON ll_order(tenant_id,source_region,source_inbound_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_list'), 'SELECT 1', 'CREATE INDEX idx_ll_order_list ON ll_order(tenant_id,current_status,created_at)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND index_name='uk_ll_loading_inbox_tenant_key'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_ll_loading_inbox_tenant_key ON ll_loading_event_inbox(tenant_id,event_type,event_id)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-15 / 006 +-- Warehouse master-data descriptions and query indexes +-- ============================================================================ + +USE baiyue_warehouse_overseas; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', remark VARCHAR(500) NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_wh_tenant_code (tenant_id,warehouse_code,deleted), + KEY idx_wh_tenant_status (tenant_id,status,deleted), KEY idx_wh_country (tenant_id,country_code,deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', state_province VARCHAR(100) NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', district VARCHAR(100) NULL COMMENT '区县', postal_code VARCHAR(20) NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) NULL COMMENT '纬度', longitude DECIMAL(10,7) NULL COMMENT '经度', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_address_type (tenant_id,warehouse_id,address_type,deleted), + KEY idx_address_warehouse (tenant_id,warehouse_id,deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', capability_code VARCHAR(50) NOT NULL COMMENT '能力编码', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME NULL COMMENT '当日作业截单时间', description VARCHAR(500) NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), UNIQUE KEY uk_capability (tenant_id,warehouse_id,capability_code,deleted), + KEY idx_capability_query (tenant_id,warehouse_id,enabled,deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='uk_wh_tenant_code'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_wh_tenant_code ON wh_warehouse(tenant_id,warehouse_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_tenant_status'), 'SELECT 1', 'CREATE INDEX idx_wh_tenant_status ON wh_warehouse(tenant_id,status,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND index_name='idx_wh_country'), 'SELECT 1', 'CREATE INDEX idx_wh_country ON wh_warehouse(tenant_id,country_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='uk_address_type'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_address_type ON wh_warehouse_address(tenant_id,warehouse_id,address_type,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND index_name='idx_address_warehouse'), 'SELECT 1', 'CREATE INDEX idx_address_warehouse ON wh_warehouse_address(tenant_id,warehouse_id,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='uk_capability'), 'SELECT 1', 'CREATE UNIQUE INDEX uk_capability ON wh_service_capability(tenant_id,warehouse_id,capability_code,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND index_name='idx_capability_query'), 'SELECT 1', 'CREATE INDEX idx_capability_query ON wh_service_capability(tenant_id,warehouse_id,enabled,deleted)')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 007 +-- Last-leg batch FBA appointments +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN fba_warehouse_code VARCHAR(128) NULL AFTER inbound_code')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; +UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id +SET l.fba_warehouse_code=o.fba_warehouse_code +WHERE l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( + id BIGINT NOT NULL COMMENT '主键ID', + tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', + appointment_at DATETIME(3) NOT NULL COMMENT '预约时间', + appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '预约状态', + appointment_remark VARCHAR(1000) NULL COMMENT '预约备注', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + selected_count INT NOT NULL DEFAULT 0 COMMENT '已选明细数量', + created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_fba_appointment_no (tenant_id, appointment_no), + KEY idx_ll_fba_appointment_list (tenant_id, appointment_status, fba_warehouse_code, created_at) +) ENGINE=InnoDB COMMENT='FBA入仓预约单'; + +CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment_item ( + id BIGINT NOT NULL COMMENT '主键ID', + appointment_id BIGINT NOT NULL COMMENT '预约单ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', + order_id BIGINT NOT NULL COMMENT '订单ID', + inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_fba_appointment_item_line (appointment_id, line_id), + KEY idx_ll_fba_appointment_item_plan (plan_id), + KEY idx_ll_fba_appointment_item_order (order_id) +) ENGINE=InnoDB COMMENT='FBA入仓预约明细'; + +-- ============================================================================ +-- 2026-08-17 / 008 +-- Last-leg inbound-order/package inventory balances, positions and immutable ledger +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inventory_balance ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + procurement_packages BIGINT NOT NULL DEFAULT 0 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_inventory_dimension (tenant_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + KEY idx_ll_inventory_list (tenant_id,overseas_warehouse_id,updated_at), + KEY idx_ll_inventory_inbound (tenant_id,inbound_code) +) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + +CREATE TABLE IF NOT EXISTS ll_inventory_position ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + batch_no VARCHAR(128) NOT NULL DEFAULT '' COMMENT '库存批次号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', + warehouse_location_code VARCHAR(128) NOT NULL COMMENT '仓库库位编码', + available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', + allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', + receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_inventory_position (balance_id,pallet_id,batch_no), + KEY idx_ll_inventory_position_pallet (tenant_id,pallet_id), + KEY idx_ll_inventory_position_location (tenant_id,overseas_warehouse_id,warehouse_location_code) +) ENGINE=InnoDB COMMENT='托盘库位批次库存明细'; + +CREATE TABLE IF NOT EXISTS ll_inventory_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', position_id BIGINT NULL COMMENT '库存仓位记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目的仓编码快照', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', + pallet_id BIGINT NULL COMMENT '托盘ID', pallet_no VARCHAR(64) NULL COMMENT '托盘号', batch_no VARCHAR(128) NULL COMMENT '库存批次号', + warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + business_order_id BIGINT NULL COMMENT '业务单据ID', business_order_no VARCHAR(128) NULL COMMENT '业务单号', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', outbound_order_no VARCHAR(128) NULL COMMENT '出库单号', + source_event_id VARCHAR(128) NULL COMMENT '来源事件ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + status_from VARCHAR(32) NULL COMMENT '操作前状态', status_to VARCHAR(32) NULL COMMENT '操作后状态', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + operator_id VARCHAR(128) NULL COMMENT '操作人ID', remark 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_inventory_ledger_idempotency (tenant_id,idempotency_key), + KEY idx_ll_inventory_ledger_query (tenant_id,overseas_warehouse_id,occurred_at), + KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at), + KEY idx_ll_inventory_ledger_business (tenant_id,business_type,business_order_no) +) ENGINE=InnoDB COMMENT='不可变库存变动流水'; + +CREATE TABLE IF NOT EXISTS ll_inventory_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING' COMMENT '库存命令处理状态', + result_json LONGTEXT NULL COMMENT '处理结果JSON', last_error VARCHAR(2000) 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_inventory_operation_key (tenant_id,idempotency_key), + KEY idx_ll_inventory_operation_status (tenant_id,status,updated_at) +) ENGINE=InnoDB COMMENT='库存命令幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 009 +-- PDA SKU/pallet receipt snapshots for stock-in closure +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', + line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', sku_name_zh VARCHAR(255) NULL COMMENT 'SKU中文名称', source_detail_ids VARCHAR(2000) NULL COMMENT '来源明细ID集合', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', + warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', + batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', received_quantity BIGINT NOT NULL COMMENT '实收产品数量', + inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (tenant_id,task_id,allocation_id,sku_code), + KEY idx_ll_stock_receipt_plan (tenant_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) +) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; + +-- 2026-08-17 warehouse virtual location and generated pallet code support. +USE baiyue_warehouse_overseas; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='pallet_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +-- ============================================================================ +-- 2026-08-17 / 010 +-- Last-leg pre-dispatch orders and pallet locks +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '司机电话', carrier_name VARCHAR(255) NULL COMMENT '承运商名称', route VARCHAR(255) NULL COMMENT '运输路线', + depart_at DATETIME(3) NULL COMMENT '发车时间', fba_appointment_no VARCHAR(64) NULL COMMENT 'FBA预约号', + fba_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', + vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', + item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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_dispatch_order_no (tenant_id,dispatch_no), + KEY idx_ll_dispatch_order_list (tenant_id,status,depart_at), KEY idx_ll_dispatch_order_vehicle (tenant_id,vehicle_plate) +) ENGINE=InnoDB COMMENT='尾程派车单'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_item ( + id BIGINT NOT NULL COMMENT '主键ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', order_no VARCHAR(64) NULL COMMENT '订单号', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + KEY idx_ll_dispatch_item_order (tenant_id,order_id), KEY idx_ll_dispatch_item_inbound (tenant_id,inbound_code), + KEY idx_ll_dispatch_item_pallet (tenant_id,plan_pallet_id,warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='派车货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', released_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_dispatch_active_pallet (tenant_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (tenant_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (tenant_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; + +-- ============================================================================ +-- 2026-08-17 / 011 +-- Dispatch PDA task and immutable pallet pick operations +-- ============================================================================ + +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NULL COMMENT '操作人姓名', warehouse_id VARCHAR(128) NULL COMMENT '仓库ID', + pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_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_dispatch_pda_task_dispatch (tenant_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_no (tenant_id,task_no) +) ENGINE=InnoDB COMMENT='派车PDA拣货任务'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', + dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', + packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + picked_at DATETIME(3) NOT NULL COMMENT '拣货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_pda_pick_key (tenant_id,idempotency_key), + KEY idx_ll_dispatch_pda_pick_item (tenant_id,task_id,dispatch_item_id) +) ENGINE=InnoDB COMMENT='派车PDA拣货记录'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID', + operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NULL COMMENT '处理结果JSON', status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', + 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_dispatch_pda_operation_key (tenant_id,idempotency_key) +) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录'; + +-- ============================================================================ +-- 2026-08-17 / 012 +-- Preserve all tail-leg orders included in one merged dispatch pallet +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN order_refs VARCHAR(2000) NULL AFTER order_no')); +PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE migration_stmt; + +-- ============================================================================ +-- 2026-08-17 / 013 +-- Partial dispatch completion and cumulative package counters +-- ============================================================================ +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER dispatch_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_id VARCHAR(128) NULL AFTER pallet_no')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL AFTER overseas_warehouse_id')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pda_task ADD COLUMN warehouse_id VARCHAR(128) NULL AFTER operator_name')); +PREPARE warehouse_scope_stmt FROM @ddl; EXECUTE warehouse_scope_stmt; DEALLOCATE PREPARE warehouse_scope_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (tenant_id,pick_id), + KEY idx_ll_dispatch_pick_order_receipt (tenant_id,receipt_id) +) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录'; + +-- Tail order cumulative picking/shipping counters. Original package_count is immutable. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 AFTER package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE tail_order_partial_stmt FROM @ddl; EXECUTE tail_order_partial_stmt; DEALLOCATE PREPARE tail_order_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 AFTER picked_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL AFTER shipped_package_count')); +PREPARE dispatch_partial_stmt FROM @ddl; EXECUTE dispatch_partial_stmt; DEALLOCATE PREPARE dispatch_partial_stmt; + +-- ============================================================================ +-- 2026-08-17 / 014 +-- Dispatch transfer to outbound and delivery documents +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_outbound_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_outbound_no (tenant_id,outbound_no), + UNIQUE KEY uk_ll_outbound_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (tenant_id,dispatch_order_id), + KEY idx_ll_outbound_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程出库单'; + +CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_outbound_pallet (tenant_id,outbound_order_id,warehouse_pallet_id), + KEY idx_ll_outbound_pallet_list (tenant_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (tenant_id,destination_warehouse_code) +) ENGINE=InnoDB COMMENT='出库托盘明细'; + +CREATE TABLE IF NOT EXISTS ll_delivery_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '派车单号', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '业务状态', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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_delivery_no (tenant_id,delivery_no), + UNIQUE KEY uk_ll_delivery_idempotency (tenant_id,idempotency_key), UNIQUE KEY uk_ll_delivery_outbound (tenant_id,outbound_order_id), + KEY idx_ll_delivery_list (tenant_id,status,created_at) +) ENGINE=InnoDB COMMENT='尾程派送单'; + +-- ============================================================================ +-- 2026-08-17 / 015 +-- Change inventory ownership from SKU/quantity to inbound-order/package count. +-- Legacy SKU columns become nullable and quantity columns remain untouched so original data is preserved. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_name')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL AFTER inbound_order_id')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL AFTER inbound_order_no')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- One legacy balance row represented one SKU. Keep each row intact and give it a +-- collision-free migration identity while retaining the best known inbound snapshots. +UPDATE ll_inventory_balance b +LEFT JOIN ( + SELECT balance_id, MAX(inbound_order_no) AS inbound_order_no, MAX(inbound_code) AS inbound_code + FROM ll_inventory_ledger GROUP BY balance_id +) l ON l.balance_id=b.id +SET b.inbound_order_id=COALESCE(b.inbound_order_id,b.id), + b.inbound_order_no=COALESCE(b.inbound_order_no,l.inbound_order_no), + b.inbound_code=COALESCE(NULLIF(b.inbound_code,''),NULLIF(l.inbound_code,''),CONCAT('LEGACY-',b.id)) +WHERE b.inbound_order_id IS NULL OR b.inbound_code IS NULL OR b.inbound_code=''; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL AFTER destination_warehouse_code')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +UPDATE ll_inventory_ledger SET inbound_order_id=COALESCE(inbound_order_id,business_order_id,balance_id) WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE inventory_v2_stmt FROM @ddl; EXECUTE inventory_v2_stmt; DEALLOCATE PREPARE inventory_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 016 +-- PDA stock receipts follow the same inbound-order/package-only inventory model. +-- Legacy SKU and quantity columns remain in place but become nullable/defaulted. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE stock_receipt_v2_stmt FROM @ddl; EXECUTE stock_receipt_v2_stmt; DEALLOCATE PREPARE stock_receipt_v2_stmt; + +-- ============================================================================ +-- 2026-08-17 / 017 +-- Customer relabel applications, cargo snapshots, and process status history +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_relabel_application ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号', lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', + customer_order_no VARCHAR(128) NOT NULL COMMENT '客户订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', remark VARCHAR(1000) NULL COMMENT '备注', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态', item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量', + total_package_count INT NOT NULL DEFAULT 0 COMMENT '总箱数', notified_package_count INT NOT NULL DEFAULT 0 COMMENT '已通知箱数', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', 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) +) ENGINE=InnoDB COMMENT='客户换标申请单'; + +CREATE TABLE IF NOT EXISTS ll_relabel_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_image_file_id VARCHAR(128) NULL COMMENT '产品图片文件ID', + product_image_file_name VARCHAR(255) NULL COMMENT '产品图片文件名', product_image_url VARCHAR(1000) NULL COMMENT '产品图片地址', + product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', total_package_count INT NOT NULL COMMENT '总箱数', notified_package_count INT NOT NULL COMMENT '已通知箱数', + shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + label_requirement VARCHAR(1000) NULL COMMENT '换标要求', relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', + relabel_file_name VARCHAR(255) NULL COMMENT '换标文件名', relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='换标货物冻结明细'; + +CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', application_id BIGINT NOT NULL COMMENT '申请单ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='换标状态变更历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_no (tenant_id,relabel_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_idempotency'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_idempotency (tenant_id,idempotency_key)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='uk_ll_relabel_application_order'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD UNIQUE KEY uk_ll_relabel_application_order (lastleg_order_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_list'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_list (tenant_id,status,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_customer'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_customer (tenant_id,customer_order_no)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND index_name='idx_ll_relabel_application_warehouse'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_warehouse (tenant_id,overseas_warehouse_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_application (tenant_id,application_id)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_sku'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_sku (tenant_id,sku_code)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND index_name='idx_ll_relabel_status_history_application'), 'SELECT 1', 'ALTER TABLE ll_relabel_status_history ADD KEY idx_ll_relabel_status_history_application (tenant_id,application_id,created_at)')); +PREPARE relabel_schema_stmt FROM @ddl; EXECUTE relabel_schema_stmt; DEALLOCATE PREPARE relabel_schema_stmt; + +-- ============================================================================ +-- 2026-08-17 / 018 +-- Existing-table correction: apply schema changes with ADD/UPDATE/ALTER guards +-- ============================================================================ +-- Sections 008-017 keep CREATE TABLE definitions for fresh databases. This +-- section is the authoritative path for databases that already contain those +-- tables. Legacy SKU/quantity columns are retained and made nullable; no +-- business rows or Outbox rows are deleted. +USE baiyue_lastleg; + +-- Tail orders: cumulative PDA picking/shipping counters. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory balance: add inbound-order identity without dropping legacy SKU data. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_name')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入库单号快照'' AFTER inbound_order_id')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码快照'' AFTER inbound_order_no')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_balance +SET inbound_order_id = COALESCE(inbound_order_id, id), + inbound_code = COALESCE(NULLIF(inbound_code, ''), CONCAT('LEGACY-', id)) +WHERE inbound_order_id IS NULL OR inbound_code IS NULL OR inbound_code = ''; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), 'ALTER TABLE ll_inventory_balance DROP INDEX idx_ll_inventory_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_inbound'), 'SELECT 1', CONCAT('ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_inbound (', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id'), 'customer_id', 'tenant_id'), ',inbound_code)'))); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Inventory positions and ledger: preserve SKU columns, add inbound identity. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN inbound_order_id BIGINT NULL COMMENT ''入仓单ID'' AFTER destination_warehouse_code')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +UPDATE ll_inventory_ledger +SET inbound_order_id = COALESCE(inbound_order_id, business_order_id, balance_id) +WHERE inbound_order_id IS NULL; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_sku'), 'ALTER TABLE ll_inventory_ledger DROP INDEX idx_ll_inventory_ledger_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND index_name='idx_ll_inventory_ledger_inbound'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD KEY idx_ll_inventory_ledger_inbound (tenant_id,inbound_code,occurred_at)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Stock receipts: keep legacy SKU/quantity snapshots but allow inbound-only rows. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NULL', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_quantity'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_quantity BIGINT NULL DEFAULT 0', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation_sku', 'SELECT 1')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation (tenant_id,task_id,allocation_id)')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- Dispatch/PDA fields are also ADD operations for existing installations. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数'' AFTER package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数'' AFTER picked_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason'), 'SELECT 1', 'ALTER TABLE ll_dispatch_order ADD COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因'' AFTER shipped_package_count')); +PREPARE migration_018 FROM @ddl; EXECUTE migration_018; DEALLOCATE PREPARE migration_018; + +-- No DROP COLUMN is included. Existing columns and business rows remain recoverable. + +-- ============================================================================ +-- 2026-08-17 / 019 +-- Warehouse transfer orders with historical customer-order/inbound bindings. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order ( + id BIGINT NOT NULL COMMENT '主键ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号', + operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码', + delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式', + estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号', + tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注', + need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', + 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) +) ENGINE=InnoDB COMMENT='客户转仓单'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_source_order ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + source_lastleg_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID', source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT '来源尾程订单号', + inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓来源历史订单绑定'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item ( + id BIGINT NOT NULL COMMENT '主键ID', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', tenant_id VARCHAR(128) NOT NULL COMMENT '租户ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称', + package_count INT NOT NULL COMMENT '箱数', shipping_mark VARCHAR(255) NULL COMMENT '运输唛头', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='转仓货物冻结明细'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_no'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_no (tenant_id,transfer_no)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_key (tenant_id,idempotency_key)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='uk_ll_wh_transfer_tail_order'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD UNIQUE KEY uk_ll_wh_transfer_tail_order (lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_list (tenant_id,status,created_at)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND index_name='idx_ll_wh_transfer_inbound'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD KEY idx_ll_wh_transfer_inbound (tenant_id,inbound_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='uk_ll_wh_transfer_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD UNIQUE KEY uk_ll_wh_transfer_source (transfer_order_id,source_lastleg_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND index_name='idx_ll_wh_transfer_source_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_source_order ADD KEY idx_ll_wh_transfer_source_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_list (tenant_id,transfer_order_id)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND index_name='idx_ll_wh_transfer_item_sku'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_item ADD KEY idx_ll_wh_transfer_item_sku (tenant_id,sku_code)')); +PREPARE transfer_019 FROM @ddl; EXECUTE transfer_019; DEALLOCATE PREPARE transfer_019; + +-- 2026-08-17 / customer naming and one-click dropship carton inventory. +-- Rename only existing columns; deployed data and index definitions remain intact. +DELIMITER // +CREATE PROCEDURE migrate_tenant_columns_to_customer() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE table_name_value VARCHAR(128); + DECLARE column_cursor CURSOR FOR + SELECT c.table_name FROM information_schema.columns c + WHERE c.table_schema=DATABASE() AND c.column_name='tenant_id' + AND NOT EXISTS (SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_schema=c.table_schema AND c2.table_name=c.table_name AND c2.column_name='customer_id'); + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done=TRUE; + OPEN column_cursor; + rename_loop: LOOP + FETCH column_cursor INTO table_name_value; + IF done THEN LEAVE rename_loop; END IF; + SET @rename_sql=CONCAT('ALTER TABLE `', table_name_value, '` RENAME COLUMN tenant_id TO customer_id'); + PREPARE rename_statement FROM @rename_sql; + EXECUTE rename_statement; + DEALLOCATE PREPARE rename_statement; + END LOOP; + CLOSE column_cursor; +END// +DELIMITER ; +CALL migrate_tenant_columns_to_customer(); +DROP PROCEDURE migrate_tenant_columns_to_customer; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN sku_code VARCHAR(128) NULL AFTER batch_no')); +PREPARE inventory_sku_020 FROM @ddl; EXECUTE inventory_sku_020; DEALLOCATE PREPARE inventory_sku_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_mark'), 'SELECT 1', IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_no'), 'ALTER TABLE ll_inventory_ledger RENAME COLUMN carton_no TO carton_mark', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_mark VARCHAR(128) NULL AFTER sku_code'))); +PREPARE inventory_carton_020 FROM @ddl; EXECUTE inventory_carton_020; DEALLOCATE PREPARE inventory_carton_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_packages'), 'SELECT 1', 'ALTER TABLE ll_inventory_ledger ADD COLUMN carton_packages BIGINT NULL AFTER carton_mark')); +PREPARE inventory_carton_qty_020 FROM @ddl; EXECUTE inventory_carton_qty_020; DEALLOCATE PREPARE inventory_carton_qty_020; + +CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_profile_order(customer_id,order_id), UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key) +) ENGINE=InnoDB COMMENT='一件代发托盘档案'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', carton_sequence INT NOT NULL COMMENT '箱唛序号', + status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE' COMMENT '业务状态', outbound_order_no VARCHAR(128) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status), + KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status) +) ENGINE=InnoDB COMMENT='一件代发箱唛明细'; +CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数', product_quantity BIGINT NOT 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_dropship_sku_inventory(customer_id,order_id,sku_code) +) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总'; +CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型', + before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数', + business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark 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_dropship_ledger_key(customer_id,idempotency_key), + KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark) +) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水'; +-- Dropship profile stores the immutable first-leg pallet number snapshot. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''Pallet number snapshot supplied by first-leg handover'' AFTER pallet_no')); +PREPARE dropship_source_pallet_no_stmt FROM @ddl; EXECUTE dropship_source_pallet_no_stmt; DEALLOCATE PREPARE dropship_source_pallet_no_stmt; + +-- Explicit index upgrades for existing installations. CREATE TABLE IF NOT EXISTS +-- does not add indexes to tables that already exist. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD KEY idx_ll_order_container (customer_id,container_no)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='uk_ll_dropship_carton'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD UNIQUE KEY uk_ll_dropship_carton (customer_id,order_id,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_status'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_status (customer_id,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND index_name='idx_ll_dropship_carton_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton ADD KEY idx_ll_dropship_carton_sku (customer_id,sku_code,status)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='uk_ll_dropship_ledger_key'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD UNIQUE KEY uk_ll_dropship_ledger_key (customer_id,idempotency_key)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND index_name='idx_ll_dropship_ledger_sku'), 'SELECT 1', 'ALTER TABLE ll_dropship_carton_ledger ADD KEY idx_ll_dropship_ledger_sku (customer_id,sku_code,carton_mark)')); +PREPARE lastleg_indexes_020 FROM @ddl; EXECUTE lastleg_indexes_020; DEALLOCATE PREPARE lastleg_indexes_020; + +-- ============================================================================ +-- 2026-08-18 / dropship multi-pallet cardinality +-- One one-click dropship inbound order may own multiple source pallets. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1')); +PREPARE dropship_drop_single_pallet_stmt FROM @ddl; EXECUTE dropship_drop_single_pallet_stmt; DEALLOCATE PREPARE dropship_drop_single_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)')); +PREPARE dropship_add_order_pallet_stmt FROM @ddl; EXECUTE dropship_add_order_pallet_stmt; DEALLOCATE PREPARE dropship_add_order_pallet_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_order(customer_id,order_id)')); +PREPARE dropship_add_order_idx_stmt FROM @ddl; EXECUTE dropship_add_order_idx_stmt; DEALLOCATE PREPARE dropship_add_order_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_inventory'), 'ALTER TABLE ll_dropship_sku_inventory DROP INDEX uk_ll_dropship_sku_inventory', 'SELECT 1')); +PREPARE dropship_drop_order_sku_stmt FROM @ddl; EXECUTE dropship_drop_order_sku_stmt; DEALLOCATE PREPARE dropship_drop_order_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_profile'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD UNIQUE KEY uk_ll_dropship_sku_profile(profile_id,sku_code)')); +PREPARE dropship_add_profile_sku_stmt FROM @ddl; EXECUTE dropship_add_profile_sku_stmt; DEALLOCATE PREPARE dropship_add_profile_sku_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='idx_ll_dropship_sku_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)')); +PREPARE dropship_add_order_sku_idx_stmt FROM @ddl; EXECUTE dropship_add_order_sku_idx_stmt; DEALLOCATE PREPARE dropship_add_order_sku_idx_stmt; + +-- Dropship event snapshots are not physical stock until overseas PDA stock-in completes. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_profile_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_profile_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_profile_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_profile_no_nullable_stmt FROM @ddl; EXECUTE dropship_profile_no_nullable_stmt; DEALLOCATE PREPARE dropship_profile_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL', 'SELECT 1')); +PREPARE dropship_carton_pallet_nullable_stmt FROM @ddl; EXECUTE dropship_carton_pallet_nullable_stmt; DEALLOCATE PREPARE dropship_carton_pallet_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='pallet_no' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no VARCHAR(128) NULL', 'SELECT 1')); +PREPARE dropship_carton_no_nullable_stmt FROM @ddl; EXECUTE dropship_carton_no_nullable_stmt; DEALLOCATE PREPARE dropship_carton_no_nullable_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_profile_pending_default_stmt FROM @ddl; EXECUTE dropship_profile_pending_default_stmt; DEALLOCATE PREPARE dropship_profile_pending_default_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND''', 'SELECT 1')); +PREPARE dropship_carton_pending_default_stmt FROM @ddl; EXECUTE dropship_carton_pending_default_stmt; DEALLOCATE PREPARE dropship_carton_pending_default_stmt; + +-- ============================================================================ +-- 2026-08-18 / 021 cargo discard application, carton reservation and approval. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_application ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合', + total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用', + total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' 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 '审核备注', + 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), + UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key), + KEY idx_ll_cargo_discard_list(customer_id,status,created_at), + KEY idx_ll_cargo_discard_warehouse(customer_id,overseas_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃申请单'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_item ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', profile_id BIGINT NOT NULL COMMENT '托盘档案ID', + sku_inventory_id BIGINT NOT NULL COMMENT 'SKU库存记录ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', + available_packages_snapshot INT NOT NULL COMMENT '可用箱数快照', discard_packages INT NOT NULL COMMENT '废弃箱数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_item(application_id,profile_id,sku_code), + KEY idx_ll_cargo_discard_item_list(customer_id,application_id), + KEY idx_ll_cargo_discard_item_inbound(customer_id,inbound_code,sku_code) +) ENGINE=InnoDB COMMENT='货物废弃SKU明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_carton ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', item_id BIGINT NOT NULL COMMENT '明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID', carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_cargo_discard_carton(application_id,carton_id), + KEY idx_ll_cargo_discard_carton_application(customer_id,application_id,item_id), + KEY idx_ll_cargo_discard_carton_identity(customer_id,carton_id) +) ENGINE=InnoDB COMMENT='货物废弃锁定箱明细'; + +CREATE TABLE IF NOT EXISTS ll_cargo_discard_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', application_id BIGINT NOT NULL COMMENT '申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_cargo_discard_history(customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='货物废弃审批历史'; + +-- ============================================================================ +-- 2026-08-18 / 022 overseas procurement approval, inventory and outbound flow. +-- ============================================================================ +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', + order_no VARCHAR(64) NOT NULL COMMENT '订单号', purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', purchase_quantity BIGINT NOT NULL COMMENT '采购数量', + budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', + purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', + overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', + remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) 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 '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', quantity BIGINT NOT NULL COMMENT '数量', + outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', + remark VARCHAR(1000) 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_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) +) ENGINE=InnoDB COMMENT='海外采购出库记录'; + +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', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', 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='海外采购审核历史'; + +-- 2026-08-18 / 023 US domestic trucking customer and platform workflow. +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', 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 '预算金额', + currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', + contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', 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', 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) COMMENT '更新时间', + 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), + 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='美国卡车运输订单'; + +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', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +-- ============================================================================ +-- 2026-08-18 / 024 warehouse transfer inventory allocation, outbound and receipt. +-- Creation and approval remain document-only. Inventory moves on outbound and receipt. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希'' AFTER allocation_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键'' AFTER allocation_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', inventory_balance_id BIGINT NOT NULL COMMENT '来源库存余额记录ID', + inbound_order_id BIGINT NOT NULL COMMENT '来源入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '来源入仓单号快照', + pallet_id BIGINT NOT NULL COMMENT '来源托盘ID', pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照', package_count INT NOT NULL COMMENT '分配箱数', + status VARCHAR(32) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key VARCHAR(255) NULL COMMENT '生效中的仓位排他键', + allocated_at DATETIME(3) NOT NULL COMMENT '分配时间', shipped_at DATETIME(3) NULL COMMENT '出库时间', received_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) +) ENGINE=InnoDB COMMENT='转仓单来源库存分配记录'; + +CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_receipt ( + id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', allocation_id BIGINT NOT NULL COMMENT '库存分配记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', source_inventory_position_id BIGINT NOT NULL COMMENT '来源库存仓位记录ID', + destination_inventory_balance_id BIGINT NOT NULL COMMENT '目标库存余额记录ID', destination_inventory_position_id BIGINT NOT NULL COMMENT '目标库存仓位记录ID', + package_count INT NOT NULL COMMENT '收货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '收货幂等键', request_hash CHAR(64) NOT NULL COMMENT '收货请求哈希', + inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID', + received_at DATETIME(3) NOT NULL COMMENT '收货时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id) +) ENGINE=InnoDB COMMENT='转仓来源仓位的目标库存收货记录'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_allocation_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_allocation_position(transfer_order_id,inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='uk_ll_wh_transfer_active_position'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD UNIQUE KEY uk_ll_wh_transfer_active_position(customer_id,active_position_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND index_name='idx_ll_wh_transfer_allocation_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_inventory_allocation ADD KEY idx_ll_wh_transfer_allocation_list(customer_id,transfer_order_id,status)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_source'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_source(transfer_order_id,source_inventory_position_id)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_key(customer_id,idempotency_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='uk_ll_wh_transfer_receipt_operation'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD UNIQUE KEY uk_ll_wh_transfer_receipt_operation(customer_id,inventory_operation_key)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND index_name='idx_ll_wh_transfer_receipt_list'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_receipt ADD KEY idx_ll_wh_transfer_receipt_list(customer_id,transfer_order_id,received_at)')); +PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024; + +-- 2026-08-18 / 024 Pallet-first US domestic trucking receipt workflow. +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='transport_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) NULL COMMENT ''运输类型''', 'SELECT 1')); +PREPARE us_truck_transport_type_stmt FROM @ddl; EXECUTE us_truck_transport_type_stmt; DEALLOCATE PREPARE us_truck_transport_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='container_type' AND is_nullable='NO'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) NULL COMMENT ''历史兼容设备类型,零担托盘业务不采集''', 'SELECT 1')); +PREPARE us_truck_container_type_stmt FROM @ddl; EXECUTE us_truck_container_type_stmt; DEALLOCATE PREPARE us_truck_container_type_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN pallet_count INT NOT NULL DEFAULT 1 COMMENT ''客户申报托盘数'' AFTER container_type')); +PREPARE us_truck_pallet_count_stmt FROM @ddl; EXECUTE us_truck_pallet_count_stmt; DEALLOCATE PREPARE us_truck_pallet_count_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='received_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数'' AFTER package_count')); +PREPARE us_truck_received_stmt FROM @ddl; EXECUTE us_truck_received_stmt; DEALLOCATE PREPARE us_truck_received_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='allocated_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已分配到合车行程的托盘数'' AFTER received_pallet_count')); +PREPARE us_truck_allocated_stmt FROM @ddl; EXECUTE us_truck_allocated_stmt; DEALLOCATE PREPARE us_truck_allocated_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='shipped_pallet_count'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已出库托盘数'' AFTER allocated_pallet_count')); +PREPARE us_truck_shipped_stmt FROM @ddl; EXECUTE us_truck_shipped_stmt; DEALLOCATE PREPARE us_truck_shipped_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_idempotency_key VARCHAR(255) NULL COMMENT ''实物收货幂等键'' AFTER remark')); +PREPARE us_truck_receipt_key_stmt FROM @ddl; EXECUTE us_truck_receipt_key_stmt; DEALLOCATE PREPARE us_truck_receipt_key_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_request_hash'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD COLUMN receipt_request_hash CHAR(64) NULL COMMENT ''实物收货请求哈希'' AFTER receipt_idempotency_key')); +PREPARE us_truck_receipt_hash_stmt FROM @ddl; EXECUTE us_truck_receipt_hash_stmt; DEALLOCATE PREPARE us_truck_receipt_hash_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND index_name='uk_ll_us_truck_receipt_key'), 'SELECT 1', 'ALTER TABLE ll_us_truck_order ADD UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key)')); +PREPARE us_truck_receipt_index_stmt FROM @ddl; EXECUTE us_truck_receipt_index_stmt; DEALLOCATE PREPARE us_truck_receipt_index_stmt; +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号', source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', + status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', + received_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_us_truck_order_pallet_no(customer_id,order_id,pallet_no), + UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) +) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';+ +-- ============================================================================ +-- 2026-08-18 / 025 Chinese table and column comments for Lastleg and Warehouse. +-- Idempotently updates metadata for databases where CREATE TABLE already ran. +-- ============================================================================ + +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND COALESCE(table_comment,'') <> '头程装柜完成事件接收幂等箱'), 'ALTER TABLE ll_loading_event_inbox COMMENT = ''头程装柜完成事件接收幂等箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''事件全局唯一标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='event_version' AND COALESCE(column_comment,'') <> '事件契约版本'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN event_version INT NOT NULL COMMENT ''事件契约版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '规范化事件负载 SHA-256 摘要'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''规范化事件负载 SHA-256 摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='payload_json' AND COALESCE(column_comment,'') <> '事件冻结负载 JSON'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN payload_json JSON NOT NULL COMMENT ''事件冻结负载 JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态:RECEIVED/PROCESSED'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RECEIVED'' COMMENT ''消费状态:RECEIVED/PROCESSED''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='received_at' AND COALESCE(column_comment,'') <> '接收时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''接收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='processed_at' AND COALESCE(column_comment,'') <> '处理完成时间'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN processed_at DATETIME(3) NULL COMMENT ''处理完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_event_inbox' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最近一次处理错误'), 'ALTER TABLE ll_loading_event_inbox MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT ''最近一次处理错误''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order' AND COALESCE(table_comment,'') <> '尾程订单主表'), 'ALTER TABLE ll_order COMMENT = ''尾程订单主表''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花 ID'), 'ALTER TABLE ll_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '尾程单号'), 'ALTER TABLE ll_order MODIFY COLUMN order_no VARCHAR(32) NOT NULL COMMENT ''尾程单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='order_type' AND COALESCE(column_comment,'') <> '订单类型'), 'ALTER TABLE ll_order MODIFY COLUMN order_type VARCHAR(32) NOT NULL DEFAULT ''FIRST_LEG'' COMMENT ''订单类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域/系统'), 'ALTER TABLE ll_order MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT ''来源区域/系统''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '最终实装 LOADED 分片 ID,建单幂等键'), 'ALTER TABLE ll_order MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT ''最终实装 LOADED 分片 ID,建单幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_inbound_id VARCHAR(128) COMMENT ''根入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_id VARCHAR(128) COMMENT ''来源订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_order MODIFY COLUMN source_order_no VARCHAR(128) COMMENT ''来源订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_order MODIFY COLUMN root_order_id VARCHAR(128) COMMENT ''根订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '货主名称'), 'ALTER TABLE ll_order MODIFY COLUMN customer_name VARCHAR(255) COMMENT ''货主名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) COMMENT ''海外仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_name VARCHAR(255) COMMENT ''海外仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_id' AND COALESCE(column_comment,'') <> '转入目的仓 ID'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_id VARCHAR(128) COMMENT ''转入目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_code' AND COALESCE(column_comment,'') <> '转入目的仓编码'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_code VARCHAR(128) COMMENT ''转入目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='transfer_warehouse_name' AND COALESCE(column_comment,'') <> '转入目的仓名称'), 'ALTER TABLE ll_order MODIFY COLUMN transfer_warehouse_name VARCHAR(255) COMMENT ''转入目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA 仓库编码'), 'ALTER TABLE ll_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) COMMENT ''FBA 仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_order MODIFY COLUMN container_no VARCHAR(64) COMMENT ''集装箱号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='container_status' AND COALESCE(column_comment,'') <> '货柜状态'), 'ALTER TABLE ll_order MODIFY COLUMN container_status VARCHAR(32) DEFAULT ''待转柜'' COMMENT ''货柜状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='abnormal' AND COALESCE(column_comment,'') <> '是否异常:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN abnormal TINYINT NOT NULL DEFAULT 0 COMMENT ''是否异常:0否1是''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='detained' AND COALESCE(column_comment,'') <> '是否暂扣:0否1是'), 'ALTER TABLE ll_order MODIFY COLUMN detained TINYINT NOT NULL DEFAULT 0 COMMENT ''是否暂扣:0否1是''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> '累计已拣箱数'), 'ALTER TABLE ll_order MODIFY COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_order MODIFY COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_cbm DECIMAL(18,4) COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '货物数量'), 'ALTER TABLE ll_order MODIFY COLUMN product_quantity INT COMMENT ''货物数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='volume_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order MODIFY COLUMN volume_weight_kg DECIMAL(18,4) COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_arrival_at DATETIME(3) COMMENT ''预计到港时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='actual_arrival_at' AND COALESCE(column_comment,'') <> '实际到港时间'), 'ALTER TABLE ll_order MODIFY COLUMN actual_arrival_at DATETIME(3) COMMENT ''实际到港时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='estimated_warehouse_at' AND COALESCE(column_comment,'') <> '预计到仓时间'), 'ALTER TABLE ll_order MODIFY COLUMN estimated_warehouse_at DATETIME(3) COMMENT ''预计到仓时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_order MODIFY COLUMN inbound_order_no VARCHAR(128) COMMENT ''入库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_order MODIFY COLUMN outbound_order_no VARCHAR(128) COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='delivery_order_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_order MODIFY COLUMN delivery_order_no VARCHAR(128) COMMENT ''派送单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='relabel_order_no' AND COALESCE(column_comment,'') <> '换标单号'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_order_no VARCHAR(128) COMMENT ''换标单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='relabel_status' AND COALESCE(column_comment,'') <> '换标状态'), 'ALTER TABLE ll_order MODIFY COLUMN relabel_status VARCHAR(32) COMMENT ''换标状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_flag' AND COALESCE(column_comment,'') <> '合托标记'), 'ALTER TABLE ll_order MODIFY COLUMN pallet_merge_flag VARCHAR(32) COMMENT ''合托标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='current_status' AND COALESCE(column_comment,'') <> '当前业务状态'), 'ALTER TABLE ll_order MODIFY COLUMN current_status VARCHAR(32) NOT NULL DEFAULT ''PENDING_ASSIGNMENT'' COMMENT ''当前业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='private_remark' AND COALESCE(column_comment,'') <> '备注(仅自己)'), 'ALTER TABLE ll_order MODIFY COLUMN private_remark VARCHAR(1000) COMMENT ''备注(仅自己)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='customer_remark' AND COALESCE(column_comment,'') <> '备注(客户)'), 'ALTER TABLE ll_order MODIFY COLUMN customer_remark VARCHAR(1000) COMMENT ''备注(客户)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='warehouse_remark' AND COALESCE(column_comment,'') <> '备注(仓库)'), 'ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) COMMENT ''备注(仓库)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT ''来源事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '来源装柜计划 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''来源装柜计划 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '来源装柜计划版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_version BIGINT COMMENT ''来源装柜计划版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_handover_version BIGINT COMMENT ''交接版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_payload_hash' AND COALESCE(column_comment,'') <> '来源事件负载摘要'), 'ALTER TABLE ll_order MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT ''来源事件负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND COALESCE(table_comment,'') <> '尾程订单实装商品冻结明细'), 'ALTER TABLE ll_order_detail COMMENT = ''尾程订单实装商品冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='id' AND COALESCE(column_comment,'') <> '订单明细主键'), 'ALTER TABLE ll_order_detail MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''订单明细主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入仓明细 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN sku_code VARCHAR(128) COMMENT ''SKU 编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_zh VARCHAR(255) COMMENT ''中文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_name_en VARCHAR(255) COMMENT ''英文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_order_detail MODIFY COLUMN product_quantity INT COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_order_detail MODIFY COLUMN pallet_count INT COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN gross_weight_kg DECIMAL(18,4) COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN net_weight_kg DECIMAL(18,4) COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN dimensional_weight_kg DECIMAL(18,4) COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_order_detail MODIFY COLUMN volume_cbm DECIMAL(18,4) COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_id VARCHAR(128) COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_code VARCHAR(128) COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_order_detail MODIFY COLUMN destination_warehouse_name VARCHAR(255) COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN fba_tracking_no VARCHAR(255) COMMENT ''FBA跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipment_no VARCHAR(255) COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_detail' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_order_detail MODIFY COLUMN shipping_mark VARCHAR(255) COMMENT ''唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态变更历史'), 'ALTER TABLE ll_loading_order_status_history COMMENT = ''尾程订单状态变更历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '状态历史主键'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''状态历史主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN from_status VARCHAR(32) COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='event_id' AND COALESCE(column_comment,'') <> '触发事件 ID'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN event_id VARCHAR(128) COMMENT ''触发事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '状态变更备注'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN remark VARCHAR(500) COMMENT ''状态变更备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_loading_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_loading_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''记录时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND COALESCE(table_comment,'') <> '事件冲突记录'), 'ALTER TABLE ll_event_conflict COMMENT = ''事件冲突记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='id' AND COALESCE(column_comment,'') <> '冲突记录主键'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN id BIGINT NOT NULL COMMENT ''冲突记录主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='event_id' AND COALESCE(column_comment,'') <> '冲突事件 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''冲突事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='conflict_type' AND COALESCE(column_comment,'') <> '冲突类型'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_type VARCHAR(64) NOT NULL COMMENT ''冲突类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='conflict_message' AND COALESCE(column_comment,'') <> '冲突说明'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN conflict_message VARCHAR(512) NOT NULL COMMENT ''冲突说明''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_conflict' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_conflict MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND COALESCE(table_comment,'') <> '通用事件接收幂等箱'), 'ALTER TABLE ll_event_inbox COMMENT = ''通用事件接收幂等箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='id' AND COALESCE(column_comment,'') <> '事件接收记录主键'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN id BIGINT NOT NULL COMMENT ''事件接收记录主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='event_id' AND COALESCE(column_comment,'') <> '事件全局唯一 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_id VARCHAR(128) NOT NULL COMMENT ''事件全局唯一 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='event_type' AND COALESCE(column_comment,'') <> '事件类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN event_type VARCHAR(128) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_type' AND COALESCE(column_comment,'') <> '聚合类型'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT ''聚合类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_id' AND COALESCE(column_comment,'') <> '聚合 ID'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT ''聚合 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='aggregate_version' AND COALESCE(column_comment,'') <> '聚合版本'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN aggregate_version BIGINT NOT NULL COMMENT ''聚合版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='status' AND COALESCE(column_comment,'') <> '消费状态'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''消费状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='error_message' AND COALESCE(column_comment,'') <> '错误信息'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN error_message VARCHAR(512) NULL COMMENT ''错误信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_inbox' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_event_inbox MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND COALESCE(table_comment,'') <> '尾程入仓单快照'), 'ALTER TABLE ll_inbound_order COMMENT = ''尾程入仓单快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓单主键'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入仓单主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_region' AND COALESCE(column_comment,'') <> '来源区域'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_region VARCHAR(32) NOT NULL COMMENT ''来源区域''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_inbound_id' AND COALESCE(column_comment,'') <> '来源入仓分片 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_inbound_id VARCHAR(128) NOT NULL COMMENT ''来源入仓分片 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='root_inbound_id' AND COALESCE(column_comment,'') <> '根入仓单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_inbound_id VARCHAR(128) NULL COMMENT ''根入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_id VARCHAR(128) NULL COMMENT ''来源订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_order_no' AND COALESCE(column_comment,'') <> '来源订单号'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_order_no VARCHAR(128) NULL COMMENT ''来源订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='root_order_id' AND COALESCE(column_comment,'') <> '根订单 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN root_order_id VARCHAR(128) NULL COMMENT ''根订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓码'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN product_quantity INT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN pallet_count INT NULL COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT ''来源事件 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '装柜计划 ID'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''装柜计划 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '装柜计划版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN source_loading_plan_version BIGINT NOT NULL COMMENT ''装柜计划版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN handover_version BIGINT NOT NULL COMMENT ''交接版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='payload_hash' AND COALESCE(column_comment,'') <> '负载摘要'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN payload_hash VARCHAR(71) NOT NULL COMMENT ''负载摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='status' AND COALESCE(column_comment,'') <> '入仓单状态'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''入仓单状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='exception_flag' AND COALESCE(column_comment,'') <> '异常标记'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN exception_flag VARCHAR(32) NOT NULL DEFAULT ''NONE'' COMMENT ''异常标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND COALESCE(table_comment,'') <> '尾程入仓商品明细快照'), 'ALTER TABLE ll_inbound_order_item COMMENT = ''尾程入仓商品明细快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='id' AND COALESCE(column_comment,'') <> '入仓明细主键'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入仓明细主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '入仓单 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''入仓单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入仓明细 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入仓明细 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_quantity INT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托数'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN pallet_count INT NULL COMMENT ''托数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='gross_weight_kg' AND COALESCE(column_comment,'') <> '毛重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN gross_weight_kg DECIMAL(18,3) NULL COMMENT ''毛重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='net_weight_kg' AND COALESCE(column_comment,'') <> '净重(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN net_weight_kg DECIMAL(18,3) NULL COMMENT ''净重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='dimensional_weight_kg' AND COALESCE(column_comment,'') <> '体积重量(千克)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN dimensional_weight_kg DECIMAL(18,3) NULL COMMENT ''体积重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN volume_cbm DECIMAL(18,6) NULL COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓 ID'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='fba_tracking_no' AND COALESCE(column_comment,'') <> 'FBA跟踪号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN fba_tracking_no VARCHAR(128) NULL COMMENT ''FBA跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '唛头'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT ''唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU 编码'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU 编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '中文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='product_name_en' AND COALESCE(column_comment,'') <> '英文品名'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_order_item MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND COALESCE(table_comment,'') <> '尾程订单状态历史'), 'ALTER TABLE ll_order_status_history COMMENT = ''尾程订单状态历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '订单状态历史主键'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''订单状态历史主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''租户 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单 ID'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单 ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='reason' AND COALESCE(column_comment,'') <> '变更原因'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN reason VARCHAR(255) NULL COMMENT ''变更原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '记录时间'), 'ALTER TABLE ll_order_status_history MODIFY COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''记录时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND COALESCE(table_comment,'') <> '尾程入库计划表:每个租户的每个集装箱生成一张入库单'), 'ALTER TABLE ll_inbound_plan COMMENT = ''尾程入库计划表:每个租户的每个集装箱生成一张入库单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_no' AND COALESCE(column_comment,'') <> '入库单号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT ''入库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '租户/客户账号标识'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''租户/客户账号标识''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='container_no' AND COALESCE(column_comment,'') <> '集装箱号'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT ''集装箱号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='status' AND COALESCE(column_comment,'') <> '入库计划状态'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PLANNED'' COMMENT ''入库计划状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='line_count' AND COALESCE(column_comment,'') <> '入仓码明细数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT ''入仓码明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '系统自动分配托盘数量'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''系统自动分配托盘数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '集装箱总体积(立方米)'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''集装箱总体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND COALESCE(table_comment,'') <> '尾程入库计划明细表:按入仓码和尾程订单维度记录'), 'ALTER TABLE ll_inbound_plan_line COMMENT = ''尾程入库计划明细表:按入仓码和尾程订单维度记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='id' AND COALESCE(column_comment,'') <> '入库计划明细ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN id BIGINT NOT NULL COMMENT ''入库计划明细ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '头程入仓码快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''头程入仓码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '入仓码体积(立方米)'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''入仓码体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='planned_pallet_count' AND COALESCE(column_comment,'') <> '该明细分配的托盘数量'), 'ALTER TABLE ll_inbound_plan_line MODIFY COLUMN planned_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''该明细分配的托盘数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND COALESCE(table_comment,'') <> '尾程入库计划托盘表:按目的仓隔离合托'), 'ALTER TABLE ll_inbound_plan_pallet COMMENT = ''尾程入库计划托盘表:按目的仓隔离合托''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '计划托盘ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN id BIGINT NOT NULL COMMENT ''计划托盘ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '计划托盘编号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''计划托盘编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量,固定为2立方米'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT ''托盘容量,固定为2立方米''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '已分配体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''已分配体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '计划内托盘序号'), 'ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN sequence_no INT NOT NULL COMMENT ''计划内托盘序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND COALESCE(table_comment,'') <> '尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'), 'ALTER TABLE ll_inbound_plan_pallet_allocation COMMENT = ''尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘分配记录ID(雪花ID)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘分配记录ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入库计划ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入库计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='allocated_volume_cbm' AND COALESCE(column_comment,'') <> '该明细分配到托盘的体积(立方米)'), 'ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT ''该明细分配到托盘的体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND COALESCE(table_comment,'') <> '入仓PDA作业任务'), 'ALTER TABLE ll_inbound_pda_task COMMENT = ''入仓PDA作业任务''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN operator_name VARCHAR(255) NULL COMMENT ''操作人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN pushed_at DATETIME(3) NULL COMMENT ''任务下发时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='started_at' AND COALESCE(column_comment,'') <> '开始时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN started_at DATETIME(3) NULL COMMENT ''开始时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND COALESCE(table_comment,'') <> '入仓PDA操作幂等记录'), 'ALTER TABLE ll_inbound_pda_operation COMMENT = ''入仓PDA操作幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN allocation_id BIGINT NULL COMMENT ''分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='action' AND COALESCE(column_comment,'') <> '操作动作'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT ''操作动作''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='request_json' AND COALESCE(column_comment,'') <> '请求内容JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN request_json LONGTEXT NOT NULL COMMENT ''请求内容JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN result_json LONGTEXT NOT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND COALESCE(table_comment,'') <> '入仓托盘合托记录'), 'ALTER TABLE ll_inbound_pallet_merge COMMENT = ''入仓托盘合托记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='merge_group_no' AND COALESCE(column_comment,'') <> '合托分组号'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN merge_group_no VARCHAR(64) NOT NULL COMMENT ''合托分组号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_id BIGINT NOT NULL COMMENT ''来源托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_pallet_id' AND COALESCE(column_comment,'') <> '目标托盘ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_pallet_id BIGINT NOT NULL COMMENT ''目标托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_allocation_id' AND COALESCE(column_comment,'') <> '来源分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_allocation_id BIGINT NOT NULL COMMENT ''来源分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_allocation_id' AND COALESCE(column_comment,'') <> '目标分配记录ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_allocation_id BIGINT NOT NULL COMMENT ''目标分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_line_id' AND COALESCE(column_comment,'') <> '来源计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_line_id BIGINT NOT NULL COMMENT ''来源计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_line_id' AND COALESCE(column_comment,'') <> '目标计划明细ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_line_id BIGINT NOT NULL COMMENT ''目标计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_order_id' AND COALESCE(column_comment,'') <> '来源订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT ''来源订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='target_order_id' AND COALESCE(column_comment,'') <> '目标订单ID'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN target_order_id BIGINT NOT NULL COMMENT ''目标订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='moved_package_count' AND COALESCE(column_comment,'') <> '移动箱数'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_package_count INT NOT NULL COMMENT ''移动箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='moved_volume_cbm' AND COALESCE(column_comment,'') <> '移动体积(立方米)'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN moved_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''移动体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='source_pallet_scrapped' AND COALESCE(column_comment,'') <> '来源托盘是否已报废'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN source_pallet_scrapped TINYINT NOT NULL DEFAULT 0 COMMENT ''来源托盘是否已报废''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pallet_merge' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_pallet_merge MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND COALESCE(table_comment,'') <> 'FBA入仓预约单'), 'ALTER TABLE ll_inbound_fba_appointment COMMENT = ''FBA入仓预约单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_no' AND COALESCE(column_comment,'') <> '预约单号'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_no VARCHAR(64) NOT NULL COMMENT ''预约单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_at' AND COALESCE(column_comment,'') <> '预约时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_at DATETIME(3) NOT NULL COMMENT ''预约时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_status' AND COALESCE(column_comment,'') <> '预约状态'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='appointment_remark' AND COALESCE(column_comment,'') <> '预约备注'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN appointment_remark VARCHAR(1000) NULL COMMENT ''预约备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='selected_count' AND COALESCE(column_comment,'') <> '已选明细数量'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN selected_count INT NOT NULL DEFAULT 0 COMMENT ''已选明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_fba_appointment MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND COALESCE(table_comment,'') <> 'FBA入仓预约明细'), 'ALTER TABLE ll_inbound_fba_appointment_item COMMENT = ''FBA入仓预约明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='appointment_id' AND COALESCE(column_comment,'') <> '预约单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN appointment_id BIGINT NOT NULL COMMENT ''预约单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入仓计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_fba_appointment_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_fba_appointment_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND COALESCE(table_comment,'') <> '入仓单维度实时库存余额'), 'ALTER TABLE ll_inventory_balance COMMENT = ''入仓单维度实时库存余额''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_code' AND COALESCE(column_comment,'') <> '海外仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN available_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''可用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''冻结箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='in_transit_packages' AND COALESCE(column_comment,'') <> '在途箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''在途箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''收货处理中箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='procurement_packages' AND COALESCE(column_comment,'') <> '采购业务占用箱数'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN procurement_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''采购业务占用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_balance MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND COALESCE(table_comment,'') <> '托盘库位批次库存明细'), 'ALTER TABLE ll_inventory_position COMMENT = ''托盘库位批次库存明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN batch_no VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN warehouse_location_code VARCHAR(128) NOT NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='available_packages' AND COALESCE(column_comment,'') <> '可用箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN available_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''可用箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='hold_packages' AND COALESCE(column_comment,'') <> '冻结箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''冻结箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='allocated_packages' AND COALESCE(column_comment,'') <> '已分配箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='picked_packages' AND COALESCE(column_comment,'') <> '已拣箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='receiving_packages' AND COALESCE(column_comment,'') <> '收货处理中箱数'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT ''收货处理中箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_position' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_position MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND COALESCE(table_comment,'') <> '不可变库存变动流水'), 'ALTER TABLE ll_inventory_ledger COMMENT = ''不可变库存变动流水''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN position_id BIGINT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN destination_warehouse_code VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN pallet_id BIGINT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN pallet_no VARCHAR(64) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN batch_no VARCHAR(128) NULL COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN warehouse_location_code VARCHAR(128) NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_mark VARCHAR(128) NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='carton_packages' AND COALESCE(column_comment,'') <> '箱内箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN carton_packages BIGINT NULL COMMENT ''箱内箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_type VARCHAR(32) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_order_id' AND COALESCE(column_comment,'') <> '业务单据ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_order_id BIGINT NULL COMMENT ''业务单据ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN business_order_no VARCHAR(128) NULL COMMENT ''业务单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='inbound_order_no' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN inbound_order_no VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='source_event_id' AND COALESCE(column_comment,'') <> '来源事件ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN source_event_id VARCHAR(128) NULL COMMENT ''来源事件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='status_from' AND COALESCE(column_comment,'') <> '操作前状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_from VARCHAR(32) NULL COMMENT ''操作前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='status_to' AND COALESCE(column_comment,'') <> '操作后状态'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN status_to VARCHAR(32) NULL COMMENT ''操作后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN before_packages BIGINT NOT NULL COMMENT ''变动前箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN delta_packages BIGINT NOT NULL COMMENT ''箱数变动量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN after_packages BIGINT NOT NULL COMMENT ''变动后箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN occurred_at DATETIME(3) NOT NULL COMMENT ''业务发生时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND COALESCE(table_comment,'') <> '库存命令幂等记录'), 'ALTER TABLE ll_inventory_operation COMMENT = ''库存命令幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='status' AND COALESCE(column_comment,'') <> '库存命令处理状态'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PROCESSING'' COMMENT ''库存命令处理状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='last_error' AND COALESCE(column_comment,'') <> '最后错误信息'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN last_error VARCHAR(2000) NULL COMMENT ''最后错误信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inventory_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND COALESCE(table_comment,'') <> '入仓PDA库存实收快照'), 'ALTER TABLE ll_inbound_stock_receipt COMMENT = ''入仓PDA库存实收快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN plan_id BIGINT NOT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '分配记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN allocation_id BIGINT NOT NULL COMMENT ''分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入仓计划明细ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN line_id BIGINT NOT NULL COMMENT ''入仓计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_pallet_id BIGINT NOT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '仓库库位ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''仓库库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='warehouse_location_code' AND COALESCE(column_comment,'') <> '仓库库位编码'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN warehouse_location_code VARCHAR(128) NULL COMMENT ''仓库库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='batch_no' AND COALESCE(column_comment,'') <> '库存批次号'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN batch_no VARCHAR(128) NOT NULL COMMENT ''库存批次号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_packages' AND COALESCE(column_comment,'') <> '实收箱数'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_packages BIGINT NOT NULL COMMENT ''实收箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '库存余额记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_balance_id BIGINT NOT NULL COMMENT ''库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) NOT NULL COMMENT ''库存操作幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN received_at DATETIME(3) NOT NULL COMMENT ''实收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND COALESCE(table_comment,'') <> '尾程派车单'), 'ALTER TABLE ll_dispatch_order COMMENT = ''尾程派车单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='vehicle_plate' AND COALESCE(column_comment,'') <> '车牌号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='driver_name' AND COALESCE(column_comment,'') <> '司机姓名'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='driver_phone' AND COALESCE(column_comment,'') <> '司机电话'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN driver_phone VARCHAR(64) NULL COMMENT ''司机电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='carrier_name' AND COALESCE(column_comment,'') <> '承运商名称'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN carrier_name VARCHAR(255) NULL COMMENT ''承运商名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='route' AND COALESCE(column_comment,'') <> '运输路线'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN route VARCHAR(255) NULL COMMENT ''运输路线''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='depart_at' AND COALESCE(column_comment,'') <> '发车时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN depart_at DATETIME(3) NULL COMMENT ''发车时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='fba_appointment_no' AND COALESCE(column_comment,'') <> 'FBA预约号'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_no VARCHAR(64) NULL COMMENT ''FBA预约号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='fba_appointment_status' AND COALESCE(column_comment,'') <> 'FBA预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN fba_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''FBA预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='vehicle_appointment_status' AND COALESCE(column_comment,'') <> '车辆预约状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN vehicle_appointment_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''车辆预约状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_DEPARTURE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN item_count INT NOT NULL DEFAULT 0 COMMENT ''明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='picked_package_count' AND COALESCE(column_comment,'') <> 'PDA累计已拣箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''PDA累计已拣箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='shipped_package_count' AND COALESCE(column_comment,'') <> '累计已出库箱数'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN shipped_package_count INT NOT NULL DEFAULT 0 COMMENT ''累计已出库箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='partial_completion_reason' AND COALESCE(column_comment,'') <> '部分完成原因'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN partial_completion_reason VARCHAR(1000) NULL COMMENT ''部分完成原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND COALESCE(table_comment,'') <> '派车货物冻结明细'), 'ALTER TABLE ll_dispatch_item COMMENT = ''派车货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN plan_id BIGINT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_id BIGINT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_no VARCHAR(64) NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='volume_cbm' AND COALESCE(column_comment,'') <> '体积(立方米)'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_stage' AND COALESCE(column_comment,'') <> '数据来源阶段'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN source_stage VARCHAR(32) NOT NULL COMMENT ''数据来源阶段''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND COALESCE(table_comment,'') <> '派车托盘排他锁'), 'ALTER TABLE ll_dispatch_pallet_lock COMMENT = ''派车托盘排他锁''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='source_pallet_key' AND COALESCE(column_comment,'') <> '来源托盘业务键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN source_pallet_key VARCHAR(160) NOT NULL COMMENT ''来源托盘业务键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='active_key' AND COALESCE(column_comment,'') <> '生效中的资源排他键'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN active_key VARCHAR(160) NULL COMMENT ''生效中的资源排他键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '入仓计划ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN plan_id BIGINT NULL COMMENT ''入仓计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '计划托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT ''锁定时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN released_at DATETIME(3) NULL COMMENT ''释放时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND COALESCE(table_comment,'') <> '派车PDA拣货任务'), 'ALTER TABLE ll_dispatch_pda_task COMMENT = ''派车PDA拣货任务''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='task_no' AND COALESCE(column_comment,'') <> 'PDA任务号'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN task_no VARCHAR(64) NOT NULL COMMENT ''PDA任务号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='operator_name' AND COALESCE(column_comment,'') <> '操作人姓名'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN operator_name VARCHAR(128) NULL COMMENT ''操作人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='pushed_at' AND COALESCE(column_comment,'') <> '任务下发时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN pushed_at DATETIME(3) NOT NULL COMMENT ''任务下发时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='completed_at' AND COALESCE(column_comment,'') <> '完成时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN completed_at DATETIME(3) NULL COMMENT ''完成时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_task' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND COALESCE(table_comment,'') <> '派车PDA拣货记录'), 'ALTER TABLE ll_dispatch_pda_pick COMMENT = ''派车PDA拣货记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN task_id BIGINT NOT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='dispatch_item_id' AND COALESCE(column_comment,'') <> '派车明细ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN dispatch_item_id BIGINT NOT NULL COMMENT ''派车明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN packages BIGINT NOT NULL COMMENT ''本次操作箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='picked_at' AND COALESCE(column_comment,'') <> '拣货时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN picked_at DATETIME(3) NOT NULL COMMENT ''拣货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND COALESCE(table_comment,'') <> '派车PDA操作幂等记录'), 'ALTER TABLE ll_dispatch_pda_operation COMMENT = ''派车PDA操作幂等记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='task_id' AND COALESCE(column_comment,'') <> 'PDA任务ID'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN task_id BIGINT NULL COMMENT ''PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='operation_type' AND COALESCE(column_comment,'') <> 'PDA操作类型'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''PDA操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='result_json' AND COALESCE(column_comment,'') <> '处理结果JSON'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN result_json LONGTEXT NULL COMMENT ''处理结果JSON''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='status' AND COALESCE(column_comment,'') <> 'PDA操作处理状态'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''PDA操作处理状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dispatch_pda_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND COALESCE(table_comment,'') <> 'PDA拣货订单归属记录'), 'ALTER TABLE ll_dispatch_pda_pick_order COMMENT = ''PDA拣货订单归属记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='pick_id' AND COALESCE(column_comment,'') <> 'PDA拣货记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN pick_id BIGINT NOT NULL COMMENT ''PDA拣货记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='receipt_id' AND COALESCE(column_comment,'') <> '入仓实收记录ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN receipt_id BIGINT NOT NULL COMMENT ''入仓实收记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='packages' AND COALESCE(column_comment,'') <> '本次操作箱数'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN packages BIGINT NOT NULL COMMENT ''本次操作箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pda_pick_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dispatch_pda_pick_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND COALESCE(table_comment,'') <> '尾程出库单'), 'ALTER TABLE ll_outbound_order COMMENT = ''尾程出库单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN outbound_no VARCHAR(64) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_OUTBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_outbound_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND COALESCE(table_comment,'') <> '出库托盘明细'), 'ALTER TABLE ll_outbound_pallet_item COMMENT = ''出库托盘明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN outbound_order_id BIGINT NOT NULL COMMENT ''出库单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='order_refs' AND COALESCE(column_comment,'') <> '关联订单信息'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN order_refs VARCHAR(2000) NULL COMMENT ''关联订单信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND COALESCE(table_comment,'') <> '尾程派送单'), 'ALTER TABLE ll_delivery_order COMMENT = ''尾程派送单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='delivery_no' AND COALESCE(column_comment,'') <> '派送单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN delivery_no VARCHAR(64) NOT NULL COMMENT ''派送单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='outbound_order_id' AND COALESCE(column_comment,'') <> '出库单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_order_id BIGINT NOT NULL COMMENT ''出库单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN outbound_no VARCHAR(64) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_order_id' AND COALESCE(column_comment,'') <> '派车单ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_order_id BIGINT NOT NULL COMMENT ''派车单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='dispatch_no' AND COALESCE(column_comment,'') <> '派车单号'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN dispatch_no VARCHAR(64) NOT NULL COMMENT ''派车单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_DELIVERY'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN package_count INT NOT NULL DEFAULT 0 COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '托盘数'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 0 COMMENT ''托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_delivery_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND COALESCE(table_comment,'') <> '客户转仓单'), 'ALTER TABLE ll_warehouse_transfer_order COMMENT = ''客户转仓单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='transfer_no' AND COALESCE(column_comment,'') <> '转仓单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN transfer_no VARCHAR(64) NOT NULL COMMENT ''转仓单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='lastleg_order_no' AND COALESCE(column_comment,'') <> '尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN lastleg_order_no VARCHAR(32) NOT NULL COMMENT ''尾程订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='operating_warehouse_id' AND COALESCE(column_comment,'') <> '作业仓库ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN operating_warehouse_id VARCHAR(128) NOT NULL COMMENT ''作业仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> 'FBA仓库编码'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NOT NULL COMMENT ''FBA仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_address' AND COALESCE(column_comment,'') <> '派送地址'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''派送地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='delivery_method' AND COALESCE(column_comment,'') <> '派送方式'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''派送方式''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_shipment_no' AND COALESCE(column_comment,'') <> 'FBA货件编号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_shipment_no VARCHAR(128) NOT NULL COMMENT ''FBA货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN tracking_no VARCHAR(255) NULL COMMENT ''物流跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_box_nos' AND COALESCE(column_comment,'') <> 'FBA箱号集合'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_box_nos VARCHAR(2000) NULL COMMENT ''FBA箱号集合''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='need_relabel' AND COALESCE(column_comment,'') <> '是否需要换标'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT ''是否需要换标''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_application_id' AND COALESCE(column_comment,'') <> '换标申请单ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_application_id BIGINT NULL COMMENT ''换标申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN relabel_no VARCHAR(64) NULL COMMENT ''换标申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key' AND COALESCE(column_comment,'') <> '库存分配幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_request_hash' AND COALESCE(column_comment,'') <> '库存分配请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN allocation_request_hash CHAR(64) NULL COMMENT ''库存分配请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_idempotency_key' AND COALESCE(column_comment,'') <> '出库幂等键'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_idempotency_key VARCHAR(255) NULL COMMENT ''出库幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_request_hash' AND COALESCE(column_comment,'') <> '出库请求哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND COALESCE(table_comment,'') <> '转仓来源历史订单绑定'), 'ALTER TABLE ll_warehouse_transfer_source_order COMMENT = ''转仓来源历史订单绑定''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='source_lastleg_order_id' AND COALESCE(column_comment,'') <> '来源尾程订单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_id BIGINT NOT NULL COMMENT ''来源尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='source_lastleg_order_no' AND COALESCE(column_comment,'') <> '来源尾程订单号'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN source_lastleg_order_no VARCHAR(32) NOT NULL COMMENT ''来源尾程订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_source_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_source_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND COALESCE(table_comment,'') <> '转仓货物冻结明细'), 'ALTER TABLE ll_warehouse_transfer_item COMMENT = ''转仓货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN product_name_zh VARCHAR(255) NOT NULL COMMENT ''产品中文名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN package_count INT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='shipping_mark' AND COALESCE(column_comment,'') <> '运输唛头'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN shipping_mark VARCHAR(255) NULL COMMENT ''运输唛头''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND COALESCE(table_comment,'') <> '转仓单来源库存分配记录'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation COMMENT = ''转仓单来源库存分配记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_position_id BIGINT NOT NULL COMMENT ''来源库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inventory_balance_id' AND COALESCE(column_comment,'') <> '来源库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inventory_balance_id BIGINT NOT NULL COMMENT ''来源库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inbound_order_id' AND COALESCE(column_comment,'') <> '来源入仓单ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_order_id BIGINT NOT NULL COMMENT ''来源入仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '来源入仓单号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''来源入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '来源托盘ID'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''来源托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''来源托盘号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='package_count' AND COALESCE(column_comment,'') <> '分配箱数'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN package_count INT NOT NULL COMMENT ''分配箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='status' AND COALESCE(column_comment,'') <> '分配状态'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''RESERVED'' COMMENT ''分配状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='active_position_key' AND COALESCE(column_comment,'') <> '生效中的仓位排他键'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN active_position_key VARCHAR(255) NULL COMMENT ''生效中的仓位排他键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='allocated_at' AND COALESCE(column_comment,'') <> '分配时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN allocated_at DATETIME(3) NOT NULL COMMENT ''分配时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='shipped_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN shipped_at DATETIME(3) NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''收货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_inventory_allocation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_warehouse_transfer_inventory_allocation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND COALESCE(table_comment,'') <> '转仓来源仓位的目标库存收货记录'), 'ALTER TABLE ll_warehouse_transfer_receipt COMMENT = ''转仓来源仓位的目标库存收货记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='id' AND COALESCE(column_comment,'') <> '雪花主键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN id BIGINT NOT NULL COMMENT ''雪花主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='transfer_order_id' AND COALESCE(column_comment,'') <> '转仓单ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN transfer_order_id BIGINT NOT NULL COMMENT ''转仓单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='allocation_id' AND COALESCE(column_comment,'') <> '库存分配记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN allocation_id BIGINT NOT NULL COMMENT ''库存分配记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='source_inventory_position_id' AND COALESCE(column_comment,'') <> '来源库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN source_inventory_position_id BIGINT NOT NULL COMMENT ''来源库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='destination_inventory_balance_id' AND COALESCE(column_comment,'') <> '目标库存余额记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_balance_id BIGINT NOT NULL COMMENT ''目标库存余额记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='destination_inventory_position_id' AND COALESCE(column_comment,'') <> '目标库存仓位记录ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN destination_inventory_position_id BIGINT NOT NULL COMMENT ''目标库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='package_count' AND COALESCE(column_comment,'') <> '收货箱数'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN package_count INT NOT NULL COMMENT ''收货箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '收货幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''收货幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '收货请求哈希'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''收货请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='inventory_operation_key' AND COALESCE(column_comment,'') <> '库存操作幂等键'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN inventory_operation_key VARCHAR(255) NOT NULL COMMENT ''库存操作幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='received_at' AND COALESCE(column_comment,'') <> '收货时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN received_at DATETIME(3) NOT NULL COMMENT ''收货时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_receipt' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_warehouse_transfer_receipt MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND COALESCE(table_comment,'') <> '客户换标申请单'), 'ALTER TABLE ll_relabel_application COMMENT = ''客户换标申请单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='relabel_no' AND COALESCE(column_comment,'') <> '换标申请单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN relabel_no VARCHAR(64) NOT NULL COMMENT ''换标申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='lastleg_order_id' AND COALESCE(column_comment,'') <> '尾程订单ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN lastleg_order_id BIGINT NOT NULL COMMENT ''尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_order_no' AND COALESCE(column_comment,'') <> '客户订单号'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN customer_order_no VARCHAR(128) NOT NULL COMMENT ''客户订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='label_deadline' AND COALESCE(column_comment,'') <> '换标截止时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN label_deadline DATETIME(3) NOT NULL COMMENT ''换标截止时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN contact_phone VARCHAR(64) NOT NULL COMMENT ''联系人电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_PROCESSING'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='item_count' AND COALESCE(column_comment,'') <> '明细数量'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN item_count INT NOT NULL DEFAULT 0 COMMENT ''明细数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN total_package_count INT NOT NULL DEFAULT 0 COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN notified_package_count INT NOT NULL DEFAULT 0 COMMENT ''已通知箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_relabel_application MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND COALESCE(table_comment,'') <> '换标货物冻结明细'), 'ALTER TABLE ll_relabel_item COMMENT = ''换标货物冻结明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_file_id' AND COALESCE(column_comment,'') <> '产品图片文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_id VARCHAR(128) NULL COMMENT ''产品图片文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_file_name' AND COALESCE(column_comment,'') <> '产品图片文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_file_name VARCHAR(255) NULL COMMENT ''产品图片文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_image_url' AND COALESCE(column_comment,'') <> '产品图片地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_image_url VARCHAR(1000) NULL COMMENT ''产品图片地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='product_name_zh' AND COALESCE(column_comment,'') <> '产品中文名称'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN product_name_zh VARCHAR(255) NOT NULL COMMENT ''产品中文名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='total_package_count' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN total_package_count INT NOT NULL COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='notified_package_count' AND COALESCE(column_comment,'') <> '已通知箱数'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN notified_package_count INT NOT NULL COMMENT ''已通知箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='shipment_no' AND COALESCE(column_comment,'') <> '货件编号'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NOT NULL COMMENT ''货件编号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='tracking_code' AND COALESCE(column_comment,'') <> '物流跟踪编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN tracking_code VARCHAR(255) NULL COMMENT ''物流跟踪编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='new_sku_code' AND COALESCE(column_comment,'') <> '换标后SKU编码'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN new_sku_code VARCHAR(128) NOT NULL COMMENT ''换标后SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='label_requirement' AND COALESCE(column_comment,'') <> '换标要求'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN label_requirement VARCHAR(1000) NULL COMMENT ''换标要求''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_id' AND COALESCE(column_comment,'') <> '换标文件ID'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_id VARCHAR(128) NULL COMMENT ''换标文件ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_name' AND COALESCE(column_comment,'') <> '换标文件名'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_name VARCHAR(255) NULL COMMENT ''换标文件名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='relabel_file_url' AND COALESCE(column_comment,'') <> '换标文件地址'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN relabel_file_url VARCHAR(1000) NULL COMMENT ''换标文件地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND COALESCE(table_comment,'') <> '换标状态变更历史'), 'ALTER TABLE ll_relabel_status_history COMMENT = ''换标状态变更历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_relabel_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND COALESCE(table_comment,'') <> '一件代发托盘档案'), 'ALTER TABLE ll_dropship_order_profile COMMENT = ''一件代发托盘档案''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no' AND COALESCE(column_comment,'') <> '来源托盘号快照'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL COMMENT ''来源托盘号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='inventory_position_id' AND COALESCE(column_comment,'') <> '库存仓位记录ID'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN inventory_position_id BIGINT NULL COMMENT ''库存仓位记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND COALESCE(table_comment,'') <> '一件代发箱唛明细'), 'ALTER TABLE ll_dropship_carton COMMENT = ''一件代发箱唛明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘ID'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '托盘号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='carton_sequence' AND COALESCE(column_comment,'') <> '箱唛序号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN carton_sequence INT NOT NULL COMMENT ''箱唛序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='outbound_order_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN outbound_order_no VARCHAR(128) NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND COALESCE(table_comment,'') <> '一件代发SKU库存汇总'), 'ALTER TABLE ll_dropship_sku_inventory COMMENT = ''一件代发SKU库存汇总''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_name VARCHAR(255) NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='package_count' AND COALESCE(column_comment,'') <> '箱数'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN package_count BIGINT NOT NULL COMMENT ''箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='product_quantity' AND COALESCE(column_comment,'') <> '产品数量'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN product_quantity BIGINT NOT NULL COMMENT ''产品数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_dropship_sku_inventory MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND COALESCE(table_comment,'') <> '一件代发箱唛库存流水'), 'ALTER TABLE ll_dropship_carton_ledger COMMENT = ''一件代发箱唛库存流水''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN carton_id BIGINT NOT NULL COMMENT ''箱唛记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='operation_type' AND COALESCE(column_comment,'') <> '操作类型'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='before_packages' AND COALESCE(column_comment,'') <> '变动前箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN before_packages BIGINT NOT NULL COMMENT ''变动前箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='delta_packages' AND COALESCE(column_comment,'') <> '箱数变动量'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN delta_packages BIGINT NOT NULL COMMENT ''箱数变动量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='after_packages' AND COALESCE(column_comment,'') <> '变动后箱数'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN after_packages BIGINT NOT NULL COMMENT ''变动后箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='business_order_no' AND COALESCE(column_comment,'') <> '业务单号'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN business_order_no VARCHAR(128) NULL COMMENT ''业务单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='occurred_at' AND COALESCE(column_comment,'') <> '业务发生时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN occurred_at DATETIME(3) NOT NULL COMMENT ''业务发生时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton_ledger' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_dropship_carton_ledger MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND COALESCE(table_comment,'') <> '货物废弃申请单'), 'ALTER TABLE ll_cargo_discard_application COMMENT = ''货物废弃申请单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='discard_no' AND COALESCE(column_comment,'') <> '废弃申请单号'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN discard_no VARCHAR(64) NOT NULL COMMENT ''废弃申请单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='overseas_warehouse_id' AND COALESCE(column_comment,'') <> '海外仓ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT ''海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='overseas_warehouse_name' AND COALESCE(column_comment,'') <> '海外仓名称快照'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='inbound_codes' AND COALESCE(column_comment,'') <> '入仓单号集合'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN inbound_codes VARCHAR(2000) NOT NULL COMMENT ''入仓单号集合''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='total_packages' AND COALESCE(column_comment,'') <> '总箱数'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_packages INT NOT NULL COMMENT ''总箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='fee_per_package' AND COALESCE(column_comment,'') <> '每箱处理费用'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN fee_per_package DECIMAL(18,2) NOT NULL COMMENT ''每箱处理费用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='total_fee' AND COALESCE(column_comment,'') <> '费用合计'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN total_fee DECIMAL(18,2) NOT NULL COMMENT ''费用合计''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +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='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_cargo_discard_application MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND COALESCE(table_comment,'') <> '货物废弃SKU明细'), 'ALTER TABLE ll_cargo_discard_item COMMENT = ''货物废弃SKU明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='inbound_code' AND COALESCE(column_comment,'') <> '入仓单号快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN inbound_code VARCHAR(128) NOT NULL COMMENT ''入仓单号快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='profile_id' AND COALESCE(column_comment,'') <> '托盘档案ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NOT NULL COMMENT ''托盘档案ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_inventory_id' AND COALESCE(column_comment,'') <> 'SKU库存记录ID'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NOT NULL COMMENT ''SKU库存记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_code VARCHAR(128) NOT NULL COMMENT ''SKU编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN product_name VARCHAR(255) NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='available_packages_snapshot' AND COALESCE(column_comment,'') <> '可用箱数快照'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN available_packages_snapshot INT NOT NULL COMMENT ''可用箱数快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='discard_packages' AND COALESCE(column_comment,'') <> '废弃箱数'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN discard_packages INT NOT NULL COMMENT ''废弃箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND COALESCE(table_comment,'') <> '货物废弃锁定箱明细'), 'ALTER TABLE ll_cargo_discard_carton COMMENT = ''货物废弃锁定箱明细''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='item_id' AND COALESCE(column_comment,'') <> '明细ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN item_id BIGINT NOT NULL COMMENT ''明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='carton_id' AND COALESCE(column_comment,'') <> '箱唛记录ID'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN carton_id BIGINT NOT NULL COMMENT ''箱唛记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '箱唛'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_carton' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_carton MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND COALESCE(table_comment,'') <> '货物废弃审批历史'), 'ALTER TABLE ll_cargo_discard_status_history COMMENT = ''货物废弃审批历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='application_id' AND COALESCE(column_comment,'') <> '申请单ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN application_id BIGINT NOT NULL COMMENT ''申请单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_cargo_discard_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND COALESCE(table_comment,'') <> '海外采购订单'), 'ALTER TABLE ll_overseas_procurement_order COMMENT = ''海外采购订单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='customer_name' AND COALESCE(column_comment,'') <> '客户名称快照'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN order_no VARCHAR(64) NOT NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_category' AND COALESCE(column_comment,'') <> '采购品类'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_category VARCHAR(128) NOT NULL COMMENT ''采购品类''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_name' AND COALESCE(column_comment,'') <> '产品名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_name VARCHAR(255) NOT NULL COMMENT ''产品名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_specification' AND COALESCE(column_comment,'') <> '产品规格'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_specification VARCHAR(500) NULL COMMENT ''产品规格''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='product_url' AND COALESCE(column_comment,'') <> '产品链接'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN product_url VARCHAR(2000) NULL COMMENT ''产品链接''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_quantity' AND COALESCE(column_comment,'') <> '采购数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_quantity BIGINT NOT NULL COMMENT ''采购数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='budget_unit_price' AND COALESCE(column_comment,'') <> '预算单价'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN budget_unit_price DECIMAL(18,2) NULL COMMENT ''预算单价''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN currency CHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='receiving_address' AND COALESCE(column_comment,'') <> '收货地址'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN receiving_address VARCHAR(1000) NULL COMMENT ''收货地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系人电话'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN contact_phone VARCHAR(64) NOT NULL COMMENT ''联系人电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_warehouse_id' AND COALESCE(column_comment,'') <> '采购入仓仓库ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_warehouse_id VARCHAR(128) NULL COMMENT ''采购入仓仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='purchase_warehouse_name' AND COALESCE(column_comment,'') <> '采购入仓仓库名称'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN purchase_warehouse_name VARCHAR(255) NULL COMMENT ''采购入仓仓库名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='inbound_quantity' AND COALESCE(column_comment,'') <> '累计入库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计入库数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='outbound_quantity' AND COALESCE(column_comment,'') <> '累计出库数量'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT ''累计出库数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_carrier' AND COALESCE(column_comment,'') <> '境内承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_carrier VARCHAR(128) NULL COMMENT ''境内承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='domestic_tracking_no' AND COALESCE(column_comment,'') <> '境内物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN domestic_tracking_no VARCHAR(128) NULL COMMENT ''境内物流单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='overseas_carrier' AND COALESCE(column_comment,'') <> '海外承运商'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_carrier VARCHAR(128) NULL COMMENT ''海外承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='overseas_tracking_no' AND COALESCE(column_comment,'') <> '海外物流单号'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN overseas_tracking_no VARCHAR(128) NULL COMMENT ''海外物流单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='estimated_arrival_at' AND COALESCE(column_comment,'') <> '预计到达时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='status' AND COALESCE(column_comment,'') <> '业务状态'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_REVIEW'' COMMENT ''业务状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='reviewed_by' AND COALESCE(column_comment,'') <> '审核人ID'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_by VARCHAR(128) NULL COMMENT ''审核人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_remark' AND COALESCE(column_comment,'') <> '审核备注'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN review_remark VARCHAR(1000) NULL COMMENT ''审核备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='reviewed_at' AND COALESCE(column_comment,'') <> '审核时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN reviewed_at DATETIME(3) NULL COMMENT ''审核时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_overseas_procurement_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND COALESCE(table_comment,'') <> '海外采购出库记录'), 'ALTER TABLE ll_overseas_procurement_outbound COMMENT = ''海外采购出库记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='quantity' AND COALESCE(column_comment,'') <> '数量'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN quantity BIGINT NOT NULL COMMENT ''数量''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='outbound_no' AND COALESCE(column_comment,'') <> '出库单号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_no VARCHAR(128) NOT NULL COMMENT ''出库单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='carrier' AND COALESCE(column_comment,'') <> '承运商'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN carrier VARCHAR(128) NULL COMMENT ''承运商''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='tracking_no' AND COALESCE(column_comment,'') <> '物流跟踪号'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''物流跟踪号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='outbound_at' AND COALESCE(column_comment,'') <> '出库时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN outbound_at DATETIME(3) NOT NULL COMMENT ''出库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_outbound' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_outbound MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND COALESCE(table_comment,'') <> '海外采购审核历史'), 'ALTER TABLE ll_overseas_procurement_status_history COMMENT = ''海外采购审核历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND COALESCE(table_comment,'') <> '美国卡车运输订单'), 'ALTER TABLE ll_us_truck_order COMMENT = ''美国卡车运输订单''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='order_no' AND COALESCE(column_comment,'') <> '订单号'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN order_no VARCHAR(64) NOT NULL COMMENT ''订单号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='transport_type' AND COALESCE(column_comment,'') <> '运输类型'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN transport_type VARCHAR(32) NOT NULL COMMENT ''运输类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='origin' AND COALESCE(column_comment,'') <> '起运地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin VARCHAR(255) NOT NULL COMMENT ''起运地''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='origin_address' AND COALESCE(column_comment,'') <> '起运地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN origin_address VARCHAR(500) NULL COMMENT ''起运地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='destination' AND COALESCE(column_comment,'') <> '目的地'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination VARCHAR(255) NOT NULL COMMENT ''目的地''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='destination_address' AND COALESCE(column_comment,'') <> '目的地址'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN destination_address VARCHAR(500) NULL COMMENT ''目的地址''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='container_type' AND COALESCE(column_comment,'') <> '历史兼容设备类型,零担托盘业务不采集'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN container_type VARCHAR(32) NULL COMMENT ''历史兼容设备类型,零担托盘业务不采集''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='pallet_count' AND COALESCE(column_comment,'') <> '客户申报托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN pallet_count INT NOT NULL DEFAULT 1 COMMENT ''客户申报托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='package_count' AND COALESCE(column_comment,'') <> '可选箱数,零或空值均有效'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN package_count INT NULL COMMENT ''可选箱数,零或空值均有效''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='received_pallet_count' AND COALESCE(column_comment,'') <> 'PDA实收托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN received_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''PDA实收托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='allocated_pallet_count' AND COALESCE(column_comment,'') <> '已分配到合车行程的托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已分配到合车行程的托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='shipped_pallet_count' AND COALESCE(column_comment,'') <> '已出库托盘数'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT ''已出库托盘数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='total_weight_kg' AND COALESCE(column_comment,'') <> '总重量(千克)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='total_volume_cbm' AND COALESCE(column_comment,'') <> '总体积(立方米)'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='budget_amount' AND COALESCE(column_comment,'') <> '预算金额'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN budget_amount DECIMAL(18,2) NULL COMMENT ''预算金额''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='currency' AND COALESCE(column_comment,'') <> '币种'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN currency VARCHAR(3) NOT NULL DEFAULT ''USD'' COMMENT ''币种''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '联系人姓名'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NOT NULL COMMENT ''联系人姓名''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='contact_info' AND COALESCE(column_comment,'') <> '联系人联系方式'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NOT NULL COMMENT ''联系人联系方式''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='requested_date' AND COALESCE(column_comment,'') <> '要求服务日期'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NOT NULL COMMENT ''要求服务日期''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='status' AND COALESCE(column_comment,'') <> '美国卡车订单状态'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''美国卡车订单状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_idempotency_key' AND COALESCE(column_comment,'') <> '实物收货幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_idempotency_key VARCHAR(255) NULL COMMENT ''实物收货幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='receipt_request_hash' AND COALESCE(column_comment,'') <> '实物收货请求哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN receipt_request_hash CHAR(64) NULL COMMENT ''实物收货请求哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='idempotency_key' AND COALESCE(column_comment,'') <> '业务幂等键'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '请求内容哈希'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT ''请求内容哈希''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_by VARCHAR(128) NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND COALESCE(table_comment,'') <> '美国卡车订单托盘实收记录'), 'ALTER TABLE ll_us_truck_order_pallet COMMENT = ''美国卡车订单托盘实收记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘记录ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='order_id' AND COALESCE(column_comment,'') <> '美国卡车订单ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''美国卡车订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='pallet_no' AND COALESCE(column_comment,'') <> '业务托盘号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT ''业务托盘号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='sequence_no' AND COALESCE(column_comment,'') <> '订单内托盘序号'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN sequence_no INT NOT NULL COMMENT ''订单内托盘序号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='source' AND COALESCE(column_comment,'') <> '托盘来源:订单计划或PDA超收'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN source VARCHAR(32) NOT NULL COMMENT ''托盘来源:订单计划或PDA超收''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:待收、已收或超收待审核'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''托盘状态:待收、已收或超收待审核''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '仓库实物托盘资源ID'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘资源ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='package_count' AND COALESCE(column_comment,'') <> '托盘箱数'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN package_count INT NULL COMMENT ''托盘箱数''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='received_at' AND COALESCE(column_comment,'') <> '实收时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''实收时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_us_truck_order_pallet MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND COALESCE(table_comment,'') <> '美国卡车订单状态历史'), 'ALTER TABLE ll_us_truck_order_status_history COMMENT = ''美国卡车订单状态历史''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='order_id' AND COALESCE(column_comment,'') <> '订单ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN order_id BIGINT NOT NULL COMMENT ''订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='from_status' AND COALESCE(column_comment,'') <> '变更前状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN from_status VARCHAR(32) NULL COMMENT ''变更前状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='to_status' AND COALESCE(column_comment,'') <> '变更后状态'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT ''变更后状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='operator_id' AND COALESCE(column_comment,'') <> '操作人ID'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN operator_id VARCHAR(128) NULL COMMENT ''操作人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN remark VARCHAR(1000) NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_us_truck_order_status_history' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE ll_us_truck_order_status_history MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + + +USE baiyue_warehouse_overseas; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_location' AND COALESCE(table_comment,'') <> '仓库库位表:按租户和仓库维护库位及承重信息'), 'ALTER TABLE wh_location COMMENT = ''仓库库位表:按租户和仓库维护库位及承重信息''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='id' AND COALESCE(column_comment,'') <> '库位ID(雪花ID)'), 'ALTER TABLE wh_location MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''库位ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_location MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_location MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='zone_code' AND COALESCE(column_comment,'') <> '仓库分区编码'), 'ALTER TABLE wh_location MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT ''仓库分区编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='location_code' AND COALESCE(column_comment,'') <> '仓库内唯一库位编码'), 'ALTER TABLE wh_location MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT ''仓库内唯一库位编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='location_type' AND COALESCE(column_comment,'') <> '库位类型'), 'ALTER TABLE wh_location MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT ''库位类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='max_load_kg' AND COALESCE(column_comment,'') <> '最大承重(千克)'), 'ALTER TABLE wh_location MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT ''最大承重(千克)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='status' AND COALESCE(column_comment,'') <> '库位状态:ACTIVE启用、INACTIVE停用'), 'ALTER TABLE wh_location MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''库位状态:ACTIVE启用、INACTIVE停用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_location MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_location MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_location MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_location MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_location MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_location MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_location MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND COALESCE(table_comment,'') <> '仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'), 'ALTER TABLE wh_pallet COMMENT = ''仓库托盘表:按租户和仓库维护托盘、容量、单价及状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘ID(雪花ID)'), 'ALTER TABLE wh_pallet MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''托盘ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='location_id' AND COALESCE(column_comment,'') <> '同仓库内绑定的库位ID,可为空'), 'ALTER TABLE wh_pallet MODIFY COLUMN location_id BIGINT UNSIGNED DEFAULT NULL COMMENT ''同仓库内绑定的库位ID,可为空''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '仓库内唯一托盘编码'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''仓库内唯一托盘编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='pallet_type' AND COALESCE(column_comment,'') <> '托盘类型'), 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT ''托盘类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='capacity_cbm' AND COALESCE(column_comment,'') <> '托盘容量(立方米)'), 'ALTER TABLE wh_pallet MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT ''托盘容量(立方米)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='unit_price' AND COALESCE(column_comment,'') <> '托盘单价'), 'ALTER TABLE wh_pallet MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT ''托盘单价''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='status' AND COALESCE(column_comment,'') <> '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃'), 'ALTER TABLE wh_pallet MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''IDLE'' COMMENT ''托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_pallet MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_pallet MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_pallet MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND COALESCE(table_comment,'') <> '仓储资源锁定记录'), 'ALTER TABLE wh_resource_lock COMMENT = ''仓储资源锁定记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN pallet_id BIGINT UNSIGNED NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='location_id' AND COALESCE(column_comment,'') <> '库位ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN location_id BIGINT UNSIGNED NOT NULL COMMENT ''库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_type VARCHAR(50) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='business_id' AND COALESCE(column_comment,'') <> '业务记录ID'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN business_id VARCHAR(100) NOT NULL COMMENT ''业务记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='reservation_key' AND COALESCE(column_comment,'') <> '资源预留幂等键'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN reservation_key VARCHAR(160) NOT NULL COMMENT ''资源预留幂等键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='status' AND COALESCE(column_comment,'') <> '资源锁状态'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''资源锁状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='locked_at' AND COALESCE(column_comment,'') <> '锁定时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN locked_at DATETIME(3) NOT NULL COMMENT ''锁定时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='released_at' AND COALESCE(column_comment,'') <> '释放时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN released_at DATETIME(3) DEFAULT NULL COMMENT ''释放时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_resource_lock' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_resource_lock MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND COALESCE(table_comment,'') <> '仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'), 'ALTER TABLE wh_warehouse COMMENT = ''仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库ID(雪花ID)'), 'ALTER TABLE wh_warehouse MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''仓库ID(雪花ID)''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '仓库所属租户ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''仓库所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_code' AND COALESCE(column_comment,'') <> '租户内唯一仓库编码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT ''租户内唯一仓库编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_name' AND COALESCE(column_comment,'') <> '仓库名称'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT ''仓库名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='warehouse_type' AND COALESCE(column_comment,'') <> '仓库类型'), 'ALTER TABLE wh_warehouse MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT ''OVERSEAS'' COMMENT ''仓库类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='country_code' AND COALESCE(column_comment,'') <> '所在国家二字码'), 'ALTER TABLE wh_warehouse MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT ''所在国家二字码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='timezone' AND COALESCE(column_comment,'') <> '仓库所在地IANA时区'), 'ALTER TABLE wh_warehouse MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT ''仓库所在地IANA时区''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_name' AND COALESCE(column_comment,'') <> '仓库联系人'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_name VARCHAR(64) DEFAULT NULL COMMENT ''仓库联系人''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_phone' AND COALESCE(column_comment,'') <> '联系电话'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_phone VARCHAR(32) DEFAULT NULL COMMENT ''联系电话''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='contact_email' AND COALESCE(column_comment,'') <> '联系邮箱'), 'ALTER TABLE wh_warehouse MODIFY COLUMN contact_email VARCHAR(128) DEFAULT NULL COMMENT ''联系邮箱''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='status' AND COALESCE(column_comment,'') <> '仓库状态'), 'ALTER TABLE wh_warehouse MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''仓库状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='remark' AND COALESCE(column_comment,'') <> '仓库备注'), 'ALTER TABLE wh_warehouse MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''仓库备注''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND COALESCE(table_comment,'') <> '仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'), 'ALTER TABLE wh_warehouse_address COMMENT = ''仓库地址表:保存运营、退货等不同用途的地址及可选经纬度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='id' AND COALESCE(column_comment,'') <> '仓库地址ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''仓库地址ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_type' AND COALESCE(column_comment,'') <> '地址类型,例如OPERATING或RETURN'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT ''OPERATING'' COMMENT ''地址类型,例如OPERATING或RETURN''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='country_code' AND COALESCE(column_comment,'') <> '国家二字码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT ''国家二字码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='state_province' AND COALESCE(column_comment,'') <> '省或州'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN state_province VARCHAR(100) DEFAULT NULL COMMENT ''省或州''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='city' AND COALESCE(column_comment,'') <> '城市'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT ''城市''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='district' AND COALESCE(column_comment,'') <> '区县'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN district VARCHAR(100) DEFAULT NULL COMMENT ''区县''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='postal_code' AND COALESCE(column_comment,'') <> '邮政编码'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN postal_code VARCHAR(20) DEFAULT NULL COMMENT ''邮政编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_line1' AND COALESCE(column_comment,'') <> '主地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT ''主地址行''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='address_line2' AND COALESCE(column_comment,'') <> '补充地址行'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN address_line2 VARCHAR(255) DEFAULT NULL COMMENT ''补充地址行''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='latitude' AND COALESCE(column_comment,'') <> '纬度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN latitude DECIMAL(10,7) DEFAULT NULL COMMENT ''纬度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='longitude' AND COALESCE(column_comment,'') <> '经度'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN longitude DECIMAL(10,7) DEFAULT NULL COMMENT ''经度''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_warehouse_address' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_warehouse_address MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND COALESCE(table_comment,'') <> '仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'), 'ALTER TABLE wh_service_capability COMMENT = ''仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='id' AND COALESCE(column_comment,'') <> '服务能力ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT ''服务能力ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '数据所属租户ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT ''数据所属租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '所属仓库ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT ''所属仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='capability_code' AND COALESCE(column_comment,'') <> '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT ''能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='capability_name' AND COALESCE(column_comment,'') <> '能力名称'), 'ALTER TABLE wh_service_capability MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT ''能力名称''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='enabled' AND COALESCE(column_comment,'') <> '是否启用'), 'ALTER TABLE wh_service_capability MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='cutoff_time' AND COALESCE(column_comment,'') <> '当日作业截单时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN cutoff_time TIME DEFAULT NULL COMMENT ''当日作业截单时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='description' AND COALESCE(column_comment,'') <> '能力说明'), 'ALTER TABLE wh_service_capability MODIFY COLUMN description VARCHAR(500) DEFAULT NULL COMMENT ''能力说明''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='updated_by' AND COALESCE(column_comment,'') <> '最后修改人ID'), 'ALTER TABLE wh_service_capability MODIFY COLUMN updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''最后修改人ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_service_capability MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='deleted' AND COALESCE(column_comment,'') <> '逻辑删除标记'), 'ALTER TABLE wh_service_capability MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''逻辑删除标记''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_service_capability' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本号'), 'ALTER TABLE wh_service_capability MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本号''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND COALESCE(table_comment,'') <> '托盘业务绑定关系'), 'ALTER TABLE wh_pallet_business_binding COMMENT = ''托盘业务绑定关系''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='id' AND COALESCE(column_comment,'') <> '主键ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN id BIGINT NOT NULL COMMENT ''主键ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN customer_id BIGINT NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '仓库ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''仓库ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='business_type' AND COALESCE(column_comment,'') <> '业务类型'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_type VARCHAR(32) NOT NULL COMMENT ''业务类型''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='business_id' AND COALESCE(column_comment,'') <> '绑定业务记录ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN business_id VARCHAR(128) NOT NULL COMMENT ''绑定业务记录ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '托盘ID'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +-- ============================================================================ +-- 2026-08-19 / 026 +-- Separate tail-leg main workflow status from child-document status +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); +PREPARE ll_order_sub_status_026 FROM @ddl; EXECUTE ll_order_sub_status_026; DEALLOCATE PREPARE ll_order_sub_status_026; diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md new file mode 100644 index 000000000..e3b1498dc --- /dev/null +++ b/warehouse-overseas-service/AGENTS.md @@ -0,0 +1,45 @@ +# Warehouse Service 模块指引 + +## 职责与边界 + +- 负责海外仓库、地址、服务能力、库位、托盘及业务资源锁定/释放。 +- 尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。 + +## 快速索引 + +| 功能 | 入口 | +|---|---| +| 平台仓库管理 | `src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java` | +| 商户仓库查询 | `controller/MerchantWarehouseController.java` | +| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | +| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` | +| 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | +| 测试 | `src/test/java/com/baiyue/warehouse/` | + +## 数据与安全规则 + +- 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 +- 内部资源接口必须校验服务令牌;普通用户不得直接调用。 + +## 数据库表索引与注释约束 + +| 业务域 | 表名 | 中文含义 | +|---|---|---| +| 库位 | `wh_location` | 海外仓库位资源 | +| 托盘 | `wh_pallet` | 海外仓实物托盘资源 | +| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 | +| 仓库 | `wh_warehouse` | 海外仓档案 | +| 仓库地址 | `wh_warehouse_address` | 海外仓地址 | +| 服务能力 | `wh_service_capability` | 海外仓服务能力 | +| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | + +- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 +- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 + +## 修改与验证 + +- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 +- 定向验证:`mvn -pl warehouse -am test`。 diff --git a/warehouse-overseas-service/pom.xml b/warehouse-overseas-service/pom.xml new file mode 100644 index 000000000..2fe0001e9 --- /dev/null +++ b/warehouse-overseas-service/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + com.baiyue.firstleg + baiyue-firstleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + warehouse-overseas-service + + + + com.baiyue.shared + baiyue-common-openapi + ${project.version} + + + com.baiyue.shared + baiyue-common-audit + ${project.version} + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baiyue.shared + baiyue-common-web + ${project.version} + + + com.baiyue.shared + baiyue-common-security + ${project.version} + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java new file mode 100644 index 000000000..1e8de8582 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/BaiyueWarehouseOverseasApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.warehouse; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。 + */ +@MapperScan("com.baiyue.warehouse.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueWarehouseOverseasApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseOverseasApplication.class, args); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java new file mode 100644 index 000000000..42efe4c60 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.config; + +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Prevents the warehouse schema migration from ever running against another database. */ +@Configuration(proxyBeanMethods = false) +public class WarehouseDatabaseGuardConfiguration { + private static final Pattern MYSQL_SCHEMA = Pattern.compile( + "^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE); + + @Bean + static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) { + String url = environment.getProperty("spring.datasource.url", "").trim(); + Matcher matcher = MYSQL_SCHEMA.matcher(url); + if (!matcher.matches() || !"baiyue_warehouse_overseas".equalsIgnoreCase(matcher.group(1))) { + throw new IllegalStateException( + "warehouse-overseas-service requires spring.datasource.url to target the baiyue_warehouse_overseas database"); + } + return beanFactory -> { }; + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java new file mode 100644 index 000000000..5cceb91f5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.DropshipPalletRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/internal/v1/warehouse-pallets") +public class InternalWarehousePalletController { + @Resource private WarehouseResourceService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/ensure-dropship") + public ApiResponse ensure( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DropshipPalletRequests.Ensure request) { + tokenGuard.verify(token); + return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId())); + } +} 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 new file mode 100644 index 000000000..1e9c74f77 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseResourceLockController.java @@ -0,0 +1,70 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehouseResourceLockService; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/warehouse-resource-locks") +public class InternalWarehouseResourceLockController { + @Resource private WarehouseResourceLockService service; + @Resource private WarehouseResourceService resourceService; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping + public ApiResponse reserve( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.reserve(request)); + } + + @PostMapping("/{lockId}/release") + public ApiResponse release( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.release(request.customerId(), lockId)); + } + + @PostMapping("/{lockId}/scrap") + public ApiResponse scrap( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long lockId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrap(request.customerId(), lockId)); + } + + @PostMapping("/pallets/{palletId}/scrap") + public ApiResponse scrapPallet( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, + @Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(service.scrapByPallet(request.customerId(), palletId)); + } + + @PostMapping("/default-locations/ensure") + public ApiResponse ensureDefaultLocation( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( + request.customerId(), request.warehouseId())); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java new file mode 100644 index 000000000..ab4d06ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -0,0 +1,67 @@ +package com.baiyue.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.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商户端仓库查询接口,仅允许在认证令牌所属租户范围内读取仓库列表和详情。 + */ +@Tag( + name = "Merchant warehouses / 商户仓库", + description = "Merchant read APIs restricted to the tenant in the authenticated JWT. / 商户端只读接口,租户范围取自认证 JWT。" +) +@RestController +@RequestMapping("/api/merchant/v1/warehouses") +public class MerchantWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "List available warehouses / 查询可用仓库", + description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid WarehouseRequests.QueryRequest query) { + return ApiResponse.success(service.page(user.tenantId(), query)); + } + + @Operation( + summary = "Get warehouse details / 查询仓库详情", + description = "Returns warehouse details only within the authenticated merchant tenant. / 仅返回当前认证商户租户内的仓库详情。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + return ApiResponse.success(service.detail(user.tenantId(), id)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java new file mode 100644 index 000000000..0ba243967 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseResourceController.java @@ -0,0 +1,109 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}") +public class MerchantWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + service.updateLocation(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid LocationRequests.QueryRequest query) { + return ApiResponse.success(service.locations(tenant(user), warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @PathVariable long id) { + return ApiResponse.success(service.location(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deleteLocation(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + service.updatePallet(tenant(user), warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, + @Valid PalletRequests.QueryRequest query) { + return ApiResponse.success(service.pallets(tenant(user), warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + return ApiResponse.success(service.pallet(tenant(user), warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long warehouseId, @PathVariable long id) { + service.deletePallet(tenant(user), warehouseId, id); + return ApiResponse.success(null); + } + + private static long tenant(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || user.tenantId() <= 0) { + throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失"); + } + return user.tenantId(); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java new file mode 100644 index 000000000..e8bcfb10f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -0,0 +1,166 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 + */ +@Validated +@Tag( + name = "Platform warehouse administration / 平台仓库管理", + description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" +) +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin") +public class PlatformWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "Create warehouse / 新建仓库", + description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(request.customerId()); + return ApiResponse.success(service.create(request)); + } + + @Operation( + summary = "Update warehouse / 更新仓库", + description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PutMapping("/{id}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(customerId); + service.update(customerId, id, request); + return ApiResponse.success(null); + } + + @Operation( + summary = "List tenant warehouses / 查询租户仓库", + description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Valid WarehouseRequests.QueryRequest request) { + requirePlatform(user); + requireTenant(customerId); + return ApiResponse.success(service.page(customerId, request)); + } + + @Operation( + summary = "Get tenant warehouse details / 查询租户仓库详情", + description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + requirePlatform(user); + requireTenant(customerId); + return ApiResponse.success(service.detail(customerId, id)); + } + + @Operation( + summary = "Enable or disable warehouse / 启用或停用仓库", + description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @PatchMapping("/{id}/status") + public ApiResponse status( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long customerId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Parameter(description = "Target status: ACTIVE or INACTIVE / 目标状态:ACTIVE 或 INACTIVE", example = "ACTIVE", required = true) + @RequestParam String status) { + requirePlatform(user); + requireTenant(customerId); + service.changeStatus(customerId, id, status); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); + } + } + + private static void requireTenant(Long customerId) { + if (customerId == null || customerId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + } + } +} + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java new file mode 100644 index 000000000..7e714246b --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -0,0 +1,131 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import jakarta.validation.Valid; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +public class PlatformWarehouseResourceController { + @Resource private WarehouseResourceService service; + + @PostMapping("/locations") + public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user, customerId); + return ApiResponse.success(service.createLocation(customerId, warehouseId, request)); + } + + @PutMapping("/locations/{id}") + public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody LocationRequests.SaveRequest request) { + requirePlatform(user, customerId); + service.updateLocation(customerId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/locations") + public ApiResponse> locations( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { + requirePlatform(user, customerId); + return ApiResponse.success(service.locations(customerId, warehouseId, query)); + } + + @GetMapping("/locations/{id}") + public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, + @PathVariable long id) { + requirePlatform(user, customerId); + return ApiResponse.success(service.location(customerId, warehouseId, id)); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, customerId); + service.deleteLocation(customerId, warehouseId, id); + return ApiResponse.success(null); + } + + @PostMapping("/pallets") + public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user, customerId); + return ApiResponse.success(service.createPallet(customerId, warehouseId, request)); + } + + @PutMapping("/pallets/{id}") + public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id, + @Valid @RequestBody PalletRequests.SaveRequest request) { + requirePlatform(user, customerId); + service.updatePallet(customerId, warehouseId, id, request); + return ApiResponse.success(null); + } + + @GetMapping("/pallets") + public ApiResponse> pallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { + requirePlatform(user, customerId); + return ApiResponse.success(service.pallets(customerId, warehouseId, query)); + } + + @GetMapping("/pallets/{id}") + public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, customerId); + return ApiResponse.success(service.pallet(customerId, warehouseId, id)); + } + + @DeleteMapping("/pallets/{id}") + public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @NotNull Long customerId, + @PathVariable long warehouseId, @PathVariable long id) { + requirePlatform(user, customerId); + service.deletePallet(customerId, warehouseId, id); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user, Long customerId) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + if (customerId == null || customerId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java new file mode 100644 index 000000000..795623301 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 仓库微服务可用性接口,向平台端暴露服务逻辑状态。 + */ +@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") +@RestController +@RequestMapping("/api/platform/v1/warehouses") +public class WarehouseHealthController { + @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-overseas-service. / 返回 warehouse-overseas-service 的逻辑运行状态。") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "warehouse-overseas-service", "status", "UP")); + } +} + diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java new file mode 100644 index 000000000..b5b99da39 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/DropshipPalletRequests.java @@ -0,0 +1,9 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class DropshipPalletRequests { + private DropshipPalletRequests() {} + public record Ensure(@Positive long customerId, @Positive long warehouseId, @NotBlank String businessId) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java new file mode 100644 index 000000000..2013ef02a --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/LocationRequests.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class LocationRequests { + private LocationRequests() { + } + + public record SaveRequest( + @NotBlank @Size(max = 50) String zoneCode, + @NotBlank @Size(max = 64) String locationCode, + @NotBlank @Size(max = 32) String locationType, + @NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg, + @NotBlank String status, + @Size(max = 500) String remark + ) { + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String zoneCode, + String locationType, + String status, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java new file mode 100644 index 000000000..f5238b4ef --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; + +public final class PalletRequests { + private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000"); + + private PalletRequests() { + } + + public record SaveRequest( + @Size(max = 128) String palletCode, + @NotBlank @Size(max = 32) String palletType, + @DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin("0.00") BigDecimal unitPrice, + @NotBlank String status, + Long locationId, + @Size(max = 500) String remark + ) { + public SaveRequest { + if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; + } + } + + public record QueryRequest( + @Min(1) long page, + @Min(1) @Max(200) long size, + String palletType, + String status, + Long locationId, + String keyword + ) { + public QueryRequest { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java new file mode 100644 index 000000000..f9ec1c882 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 仓库聚合详情响应,组合仓库主数据、地址列表和服务能力列表。 + */ +@Schema(name = "WarehouseDetailResponse", description = "Warehouse aggregate details / 仓库聚合详情") +public record WarehouseDetailResponse( + @Schema(description = "Warehouse master data / 仓库主数据") WarehouseEntity warehouse, + @Schema(description = "Warehouse addresses / 仓库地址列表") List addresses, + @Schema(description = "Warehouse service capabilities / 仓库服务能力列表") List capabilities +) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java new file mode 100644 index 000000000..386fb007c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletScrapResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java new file mode 100644 index 000000000..034dde3c6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -0,0 +1,117 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +/** + * 仓库接口请求模型容器,集中定义仓库保存、地址、服务能力、查询和状态变更请求契约。 + */ +public final class WarehouseRequests { + private WarehouseRequests() { + } + + @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") + public record SaveRequest( + @Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull Long customerId, + @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 120) String warehouseName, + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String warehouseType, + @Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String timezone, + @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) + @Size(max = 64) String contactName, + @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) + @Size(max = 32) String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") + @Email String contactEmail, + @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String status, + @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) + @Size(max = 500) String remark, + @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid @NotNull AddressRequest address, + @Schema(description = "Supported service capabilities / 支持的服务能力") + @Valid List capabilities + ) { + } + + @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") + public record AddressRequest( + @Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String addressType, + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "State or province / 州或省", example = "California", maxLength = 100) + @Size(max = 100) String stateProvince, + @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String city, + @Schema(description = "District / 区县", example = "Commerce", maxLength = 100) + @Size(max = 100) String district, + @Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20) + @Size(max = 20) String postalCode, + @Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 255) String addressLine1, + @Schema(description = "Address line 2 / 地址第二行", example = "Building A", maxLength = 255) + @Size(max = 255) String addressLine2, + @Schema(description = "Latitude / 纬度", example = "34.052235") + BigDecimal latitude, + @Schema(description = "Longitude / 经度", example = "-118.243683") + BigDecimal longitude + ) { + } + + @Schema(name = "WarehouseCapabilityRequest", description = "Warehouse service capability / 仓库服务能力") + public record CapabilityRequest( + @Schema(description = "Capability code / 能力编码", example = "STORAGE", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String capabilityCode, + @Schema(description = "Capability name / 能力名称", example = "Storage / 仓储", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String capabilityName, + @Schema(description = "Whether enabled / 是否启用", example = "true", defaultValue = "true") + Boolean enabled, + @Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time") + LocalTime cutoffTime, + @Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储", maxLength = 500) + @Size(max = 500) String description + ) { + } + + @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") + public record QueryRequest( + @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") + @Min(1) long page, + @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") + @Min(1) @Max(200) long size, + @Schema(description = "Country filter / 国家筛选", example = "US") + String countryCode, + @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") + String capabilityCode, + @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + String status + ) { + public QueryRequest { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java new file mode 100644 index 000000000..042ec6dea --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockRequests.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public final class WarehouseResourceLockRequests { + private WarehouseResourceLockRequests() {} + + public record ReserveRequest( + @Positive long customerId, + @Positive long warehouseId, + @Positive long palletId, + @Positive long locationId, + @NotBlank String businessType, + @NotBlank String businessId, + @NotBlank String reservationKey + ) {} + + public record CloseRequest(@Positive long customerId) {} + + public record DefaultLocationRequest( + @Positive long customerId, + @Positive long warehouseId + ) {} +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java new file mode 100644 index 000000000..332b92ba5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseResourceLockResponse.java @@ -0,0 +1,25 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; + +import java.time.LocalDateTime; + +public record WarehouseResourceLockResponse( + long lockId, + long customerId, + long warehouseId, + long palletId, + long locationId, + String businessType, + String businessId, + String reservationKey, + String status, + LocalDateTime lockedAt, + LocalDateTime releasedAt +) { + public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) { + return new WarehouseResourceLockResponse(entity.getId(), entity.getCustomerId(), entity.getWarehouseId(), + entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(), + entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt()); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java new file mode 100644 index 000000000..2ed0103e1 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java @@ -0,0 +1,57 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalTime; + +/** + * 仓库服务能力持久化实体,对应 {@code wh_service_capability} 表。 + * 用于记录租户仓库可提供的能力、启用状态和每日截单时间。 + */ +@Schema(name = "WarehouseServiceCapability", description = "仓库服务能力") +@Getter +@Setter +@TableName("wh_service_capability") +public class ServiceCapabilityEntity extends BaseEntity { + @Schema(description = "能力记录编号", example = "190000000000000021") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "能力编码", example = "STORAGE") + private String capabilityCode; + @Schema(description = "能力名称", example = "仓储") + private String capabilityName; + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + @Schema(description = "每日截单时间", example = "16:00:00", type = "string", format = "time") + private LocalTime cutoffTime; + @Schema(description = "能力说明", example = "标准托盘仓储") + private String description; + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java new file mode 100644 index 000000000..e43afda65 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java @@ -0,0 +1,77 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +/** + * 仓库地址持久化实体,对应 {@code wh_warehouse_address} 表。 + * 用于保存租户仓库的结构化地址与经纬度信息。 + */ +@Schema(name = "WarehouseAddress", description = "仓库地址数据") +@Getter +@Setter +@TableName("wh_warehouse_address") +public class WarehouseAddressEntity extends BaseEntity { + @Schema(description = "地址编号", example = "190000000000000011") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "地址类型", example = "WAREHOUSE") + private String addressType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "州或省", example = "California") + private String stateProvince; + @Schema(description = "城市", example = "Los Angeles") + private String city; + @Schema(description = "区县", example = "Commerce") + private String district; + @Schema(description = "邮政编码", example = "90040") + private String postalCode; + @Schema(description = "地址第一行", example = "123 Logistics Ave") + private String addressLine1; + @Schema(description = "地址第二行", example = "Building A") + private String addressLine2; + @Schema(description = "纬度", example = "34.052235") + private BigDecimal latitude; + @Schema(description = "经度", example = "-118.243683") + private BigDecimal longitude; + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java new file mode 100644 index 000000000..2a8c43527 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -0,0 +1,71 @@ +package com.baiyue.warehouse.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 仓库主数据持久化实体,对应 {@code wh_warehouse} 表。 + * 用于记录租户下仓库的编码、类型、国家、联系方式和启停状态。 + */ +@Schema(name = "Warehouse", description = "仓库主数据") +@Getter +@Setter +@TableName("wh_warehouse") +public class WarehouseEntity extends BaseEntity { + @Schema(description = "仓库编号", example = "190000000000000001") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long customerId; + @Schema(description = "仓库编码", example = "US-LAX-01") + private String warehouseCode; + @Schema(description = "仓库名称", example = "洛杉矶主仓") + private String warehouseName; + @Schema(description = "仓库类型", example = "OVERSEAS") + private String warehouseType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "IANA 时区", example = "America/Los_Angeles") + private String timezone; + @Schema(description = "联系人", example = "陈先生") + private String contactName; + @Schema(description = "联系电话", example = "+1-213-555-0100") + private String contactPhone; + @Schema(description = "联系邮箱", example = "warehouse@example.com") + private String contactEmail; + @Schema(description = "仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + private String status; + @Schema(description = "备注", example = "支持预约入库") + private String remark; + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java new file mode 100644 index 000000000..9027321f5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseLocationEntity.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_location") +public class WarehouseLocationEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private String zoneCode; + private String locationCode; + private String locationType; + private BigDecimal maxLoadKg; + private String status; + private String remark; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java new file mode 100644 index 000000000..b456960b9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBindingEntity.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.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("wh_pallet_business_binding") +public class WarehousePalletBindingEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long customerId; + private Long warehouseId; + private String businessType; + private String businessId; + private Long palletId; + private LocalDateTime createdAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java new file mode 100644 index 000000000..d6d356979 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java @@ -0,0 +1,27 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baiyue.common.core.model.BaseEntity; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@TableName("wh_pallet") +public class WarehousePalletEntity extends BaseEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long locationId; + private String palletCode; + private String palletType; + private BigDecimal capacityCbm; + private BigDecimal unitPrice; + private String status; + private String remark; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java new file mode 100644 index 000000000..e401318e9 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseResourceLockEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("wh_resource_lock") +public class WarehouseResourceLockEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long customerId; + private Long warehouseId; + private Long palletId; + private Long locationId; + private String businessType; + private String businessId; + private String reservationKey; + private String status; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java new file mode 100644 index 000000000..7f265bccb --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库服务能力 MyBatis-Plus Mapper,负责 {@code wh_service_capability} 表访问及仓库子记录的物理清理。 + */ +public interface ServiceCapabilityMapper extends BaseMapper { + + @Delete("DELETE FROM wh_service_capability WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java new file mode 100644 index 000000000..620f0e0c8 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库地址 MyBatis-Plus Mapper,负责 {@code wh_warehouse_address} 表访问及仓库子记录的物理清理。 + */ +public interface WarehouseAddressMapper extends BaseMapper { + + @Delete("DELETE FROM wh_warehouse_address WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java new file mode 100644 index 000000000..813b2a69d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseLocationMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +public interface WarehouseLocationMapper extends BaseMapper { + /** + * The unique location-code index makes this safe for concurrent first use of an older warehouse. + */ + @Insert(""" + INSERT IGNORE INTO wh_location + (id, customer_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark, deleted, version) + VALUES + (#{location.id}, #{location.customerId}, #{location.warehouseId}, #{location.zoneCode}, + #{location.locationCode}, #{location.locationType}, #{location.maxLoadKg}, + #{location.status}, #{location.remark}, 0, 0) + """) + int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java new file mode 100644 index 000000000..5c9ef0172 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseEntity; + +/** + * 仓库主数据 MyBatis-Plus Mapper,提供 {@code wh_warehouse} 表的单表持久化访问。 + */ +public interface WarehouseMapper extends BaseMapper {} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java new file mode 100644 index 000000000..d0e7b44c5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.warehouse.mapper; + +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +public interface WarehousePalletBindingMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_business_binding WHERE customer_id=#{customerId} AND warehouse_id=#{warehouseId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1") + WarehousePalletBindingEntity find(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("businessType") String businessType, @Param("businessId") String businessId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java new file mode 100644 index 000000000..5e2f5ca0d --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; + +public interface WarehousePalletMapper extends BaseMapper { +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java new file mode 100644 index 000000000..962a33999 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseResourceLockMapper.java @@ -0,0 +1,100 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehouseResourceLockMapper extends BaseMapper { + @Select(""" + SELECT * FROM wh_resource_lock + WHERE customer_id = #{customerId} AND reservation_key = #{reservationKey} + LIMIT 1 + """) + WarehouseResourceLockEntity findByReservationKey(@Param("customerId") long customerId, + @Param("reservationKey") String reservationKey); + + @Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " + + "AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1") + WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId, + @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_pallet + SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'IDLE' AND deleted = 0 + """) + int occupyLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int releasePalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_location + SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND status = 'OCCUPIED' AND deleted = 0 + """) + int releaseLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, @Param("locationId") long locationId); + + @Update(""" + UPDATE wh_pallet + SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId} + AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0 + """) + int scrapPalletAtVirtualLocation(@Param("customerId") long customerId, + @Param("warehouseId") long warehouseId, + @Param("palletId") long palletId, + @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE customer_id=#{customerId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0") + int scrapPalletWithoutLock(@Param("customerId") long customerId, @Param("palletId") long palletId); + + @Update(""" + UPDATE wh_resource_lock + SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3) + WHERE id = #{id} AND status = 'ACTIVE' + """) + int closeLock(@Param("id") long id, @Param("status") String status); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java new file mode 100644 index 000000000..9382e29d2 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -0,0 +1,112 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseAddressMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +import java.util.List; + +/** + * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 + */ +@Repository +public class WarehouseRepository { + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseLocationMapper locations; + @Resource private WarehouseAddressMapper addresses; + @Resource private ServiceCapabilityMapper capabilities; + + public void insertWarehouse(WarehouseEntity warehouse) { + warehouses.insert(warehouse); + } + + public void insertLocation(WarehouseLocationEntity location) { + locations.insert(location); + } + + public boolean existsLocationCode(long customerId, long warehouseId, String locationCode) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0; + } + + public void saveWarehouse(WarehouseEntity warehouse) { + warehouses.updateById(warehouse); + } + + public WarehouseEntity findOwned(long customerId, long id) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, id)); + } + + public boolean existsCode(long customerId, String code, Long excludedId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getWarehouseCode, code) + .ne(excludedId != null, WarehouseEntity::getId, excludedId)) > 0; + } + + public PageResult page(long customerId, WarehouseRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) + .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .orderByDesc(WarehouseEntity::getCreatedAt); + + if (StringUtils.hasText(query.capabilityCode())) { + List ids = capabilities.selectList(new LambdaQueryWrapper() + .select(ServiceCapabilityEntity::getWarehouseId) + .eq(ServiceCapabilityEntity::getCustomerId, customerId) + .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) + .eq(ServiceCapabilityEntity::getEnabled, true)) + .stream() + .map(ServiceCapabilityEntity::getWarehouseId) + .distinct() + .toList(); + if (ids.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + wrapper.in(WarehouseEntity::getId, ids); + } + return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public List findAddresses(long customerId, long warehouseId) { + return addresses.selectList(new LambdaQueryWrapper() + .eq(WarehouseAddressEntity::getCustomerId, customerId) + .eq(WarehouseAddressEntity::getWarehouseId, warehouseId)); + } + + public List findCapabilities(long customerId, long warehouseId) { + return capabilities.selectList(new LambdaQueryWrapper() + .eq(ServiceCapabilityEntity::getCustomerId, customerId) + .eq(ServiceCapabilityEntity::getWarehouseId, warehouseId)); + } + + public void physicallyDeleteChildren(long customerId, long warehouseId) { + addresses.physicallyDeleteByWarehouse(customerId, warehouseId); + capabilities.physicallyDeleteByWarehouse(customerId, warehouseId); + } + + public void insertAddress(WarehouseAddressEntity address) { + addresses.insert(address); + } + + public void insertCapability(ServiceCapabilityEntity capability) { + capabilities.insert(capability); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java new file mode 100644 index 000000000..03933ca81 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -0,0 +1,90 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceLockRepository { + @Resource private WarehouseResourceLockMapper locks; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehouseLocationMapper locations; + + public WarehouseResourceLockEntity findByReservationKey(long customerId, String reservationKey) { + return locks.findByReservationKey(customerId, reservationKey); + } + + public WarehouseResourceLockEntity find(long customerId, long lockId) { + return locks.selectOne(new LambdaQueryWrapper() + .eq(WarehouseResourceLockEntity::getCustomerId, customerId) + .eq(WarehouseResourceLockEntity::getId, lockId)); + } + + public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) { + return locks.findActiveByPallet(customerId, palletId); + } + + public WarehousePalletEntity findPallet(long customerId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, palletId)); + } + + public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long locationId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, locationId)); + } + + public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) { + return locks.occupyPallet(customerId, warehouseId, palletId, locationId); + } + + public int occupyLocation(long customerId, long warehouseId, long locationId) { + return locks.occupyLocation(customerId, warehouseId, locationId); + } + + public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); } + + public int releasePallet(WarehouseResourceLockEntity lock) { + return locks.releasePallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.releasePalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPallet(WarehouseResourceLockEntity lock) { + return locks.scrapPallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + } + + public int scrapPalletWithoutLock(long customerId, long palletId) { + return locks.scrapPalletWithoutLock(customerId, palletId); + } + + public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { + return locks.scrapPalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + lock.getPalletId(), lock.getLocationId()); + } + + public int releaseLocation(WarehouseResourceLockEntity lock) { + return locks.releaseLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + } + + public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java new file mode 100644 index 000000000..7c02c7dbf --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -0,0 +1,158 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.mapper.WarehouseLocationMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import com.baiyue.warehouse.mapper.WarehousePalletMapper; +import com.baiyue.warehouse.mapper.WarehousePalletBindingMapper; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import jakarta.annotation.Resource; + +@Repository +public class WarehouseResourceRepository { + @Resource private WarehouseMapper warehouses; + @Resource private WarehouseLocationMapper locations; + @Resource private WarehousePalletMapper pallets; + @Resource private WarehousePalletBindingMapper bindings; + + public WarehousePalletBindingEntity findPalletBinding(long customerId, long warehouseId, + String businessType, String businessId) { + return bindings.find(customerId, warehouseId, businessType, businessId); + } + + public void insertPalletBinding(WarehousePalletBindingEntity entity) { + bindings.insert(entity); + } + + public boolean warehouseOwned(long customerId, long warehouseId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, warehouseId)) > 0; + } + + public WarehouseEntity findWarehouse(long customerId, long warehouseId) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getCustomerId, customerId) + .eq(WarehouseEntity::getId, warehouseId)); + } + + public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationType, "VIRTUAL") + .eq(WarehouseLocationEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseLocationEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public WarehouseLocationEntity findLocationByCode(long customerId, long warehouseId, String locationCode) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, locationCode)); + } + + public void insertLocation(WarehouseLocationEntity entity) { + locations.insert(entity); + } + + public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) { + return locations.insertDefaultVirtualLocationIgnore(entity); + } + + public void updateLocation(WarehouseLocationEntity entity) { + locations.updateById(entity); + } + + public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long id) { + return locations.selectOne(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getId, id)); + } + + public boolean locationCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return locations.selectCount(new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(WarehouseLocationEntity::getLocationCode, code) + .ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0; + } + + public PageResult pageLocations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode()) + .eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType()) + .eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status()) + .and(StringUtils.hasText(query.keyword()), q -> q + .like(WarehouseLocationEntity::getLocationCode, query.keyword()) + .or().like(WarehouseLocationEntity::getZoneCode, query.keyword())) + .orderByAsc(WarehouseLocationEntity::getZoneCode) + .orderByAsc(WarehouseLocationEntity::getLocationCode); + return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deleteLocation(long id) { + locations.deleteById(id); + } + + public boolean locationHasPallets(long customerId, long warehouseId, long locationId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getLocationId, locationId)) > 0; + } + + public void insertPallet(WarehousePalletEntity entity) { + pallets.insert(entity); + } + + public void updatePallet(WarehousePalletEntity entity) { + pallets.updateById(entity); + } + + public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) { + return pallets.selectOne(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getId, id)); + } + + public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) { + return pallets.selectCount(new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(WarehousePalletEntity::getPalletCode, code) + .ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0; + } + + public PageResult pagePallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + var wrapper = new LambdaQueryWrapper() + .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getWarehouseId, warehouseId) + .eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType()) + .eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status()) + .eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId()) + .like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword()) + .orderByDesc(WarehousePalletEntity::getCreatedAt); + return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public void deletePallet(long id) { + pallets.deleteById(id); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..34ec759d3 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + private final byte[] expected; + + public InternalServiceTokenGuard( + @Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) { + this.expected = token.getBytes(StandardCharsets.UTF_8); + } + + public void verify(String provided) { + byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token"); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java new file mode 100644 index 000000000..a10c1367c --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -0,0 +1,176 @@ +package com.baiyue.warehouse.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.math.BigDecimal; + +/** + * 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。 + */ +@Service +public class WarehouseApplicationService { + @Resource + private WarehouseRepository repository; + + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_CREATE", + resourceType = "WAREHOUSE", + resourceId = "#r.warehouseCode", + description = "新建仓库", + captureBefore = false + ) + public Long create(WarehouseRequests.SaveRequest request) { + ensureCodeUnique(request.customerId(), request.warehouseCode(), null); + WarehouseEntity warehouse = toEntity(request); + repository.insertWarehouse(warehouse); + ensureDefaultVirtualLocation(warehouse); + insertChildren(warehouse.getCustomerId(), warehouse.getId(), request.address(), request.capabilities()); + return warehouse.getId(); + } + + private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) { + String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT"; + if (repository.existsLocationCode(warehouse.getCustomerId(), warehouse.getId(), locationCode)) { + return; + } + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setCustomerId(warehouse.getCustomerId()); + location.setWarehouseId(warehouse.getId()); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertLocation(location); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_UPDATE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "更新仓库" + ) + public void update(long customerId, long id, WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || customerId != request.customerId()) { + throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); + } + WarehouseEntity old = owned(customerId, id); + ensureCodeUnique(customerId, request.warehouseCode(), id); + + WarehouseEntity warehouse = toEntity(request); + warehouse.setId(old.getId()); + repository.saveWarehouse(warehouse); + + repository.physicallyDeleteChildren(customerId, id); + insertChildren(customerId, id, request.address(), request.capabilities()); + } + + public PageResult page(long customerId, WarehouseRequests.QueryRequest query) { + return repository.page(customerId, query); + } + + public WarehouseDetailResponse detail(long customerId, long id) { + WarehouseEntity warehouse = owned(customerId, id); + List addresses = repository.findAddresses(customerId, id); + List capabilities = repository.findCapabilities(customerId, id); + return new WarehouseDetailResponse(warehouse, addresses, capabilities); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_STATUS_CHANGE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "启用或停用仓库" + ) + public void changeStatus(long customerId, long id, String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException("无效状态"); + } + WarehouseEntity warehouse = owned(customerId, id); + warehouse.setStatus(status); + repository.saveWarehouse(warehouse); + } + + private WarehouseEntity owned(long customerId, long id) { + WarehouseEntity warehouse = repository.findOwned(customerId, id); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "仓库不存在"); + } + return warehouse; + } + + private void ensureCodeUnique(long customerId, String code, Long excludedId) { + if (repository.existsCode(customerId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "仓库编码已存在"); + } + } + + private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setCustomerId(request.customerId()); + warehouse.setWarehouseCode(request.warehouseCode()); + warehouse.setWarehouseName(request.warehouseName()); + warehouse.setWarehouseType(request.warehouseType()); + warehouse.setCountryCode(request.countryCode()); + warehouse.setTimezone(request.timezone()); + warehouse.setContactName(request.contactName()); + warehouse.setContactPhone(request.contactPhone()); + warehouse.setContactEmail(request.contactEmail()); + warehouse.setStatus(request.status()); + warehouse.setRemark(request.remark()); + return warehouse; + } + + private void insertChildren(long customerId, long warehouseId, + WarehouseRequests.AddressRequest address, + List requestedCapabilities) { + WarehouseAddressEntity addressEntity = new WarehouseAddressEntity(); + addressEntity.setCustomerId(customerId); + addressEntity.setWarehouseId(warehouseId); + addressEntity.setAddressType(address.addressType()); + addressEntity.setCountryCode(address.countryCode()); + addressEntity.setStateProvince(address.stateProvince()); + addressEntity.setCity(address.city()); + addressEntity.setDistrict(address.district()); + addressEntity.setPostalCode(address.postalCode()); + addressEntity.setAddressLine1(address.addressLine1()); + addressEntity.setAddressLine2(address.addressLine2()); + addressEntity.setLatitude(address.latitude()); + addressEntity.setLongitude(address.longitude()); + repository.insertAddress(addressEntity); + + if (requestedCapabilities == null) { + return; + } + for (WarehouseRequests.CapabilityRequest capability : requestedCapabilities) { + ServiceCapabilityEntity entity = new ServiceCapabilityEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setCapabilityCode(capability.capabilityCode()); + entity.setCapabilityName(capability.capabilityName()); + entity.setEnabled(capability.enabled() == null || capability.enabled()); + entity.setCutoffTime(capability.cutoffTime()); + entity.setDescription(capability.description()); + repository.insertCapability(entity); + } + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java new file mode 100644 index 000000000..b3ef444ff --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceLockService.java @@ -0,0 +1,158 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +public class WarehouseResourceLockService { + @Resource private WarehouseResourceLockRepository repository; + + @Transactional(isolation = Isolation.READ_COMMITTED) + public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + + WarehousePalletEntity pallet = repository.findPallet(request.customerId(), request.warehouseId(), request.palletId()); + WarehouseLocationEntity location = repository.findLocation(request.customerId(), request.warehouseId(), request.locationId()); + if (pallet == null || location == null) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse"); + } + boolean virtualLocation = isVirtual(location); + boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus()); + if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) { + WarehouseResourceLockEntity concurrent = + repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (concurrent != null) return idempotentResult(concurrent, request); + throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved"); + } + if (repository.occupyPallet(request.customerId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) { + return concurrentIdempotentOrConflict(request); + } + if (!virtualLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE"); + } + + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setCustomerId(request.customerId()); + lock.setWarehouseId(request.warehouseId()); + lock.setPalletId(request.palletId()); + lock.setLocationId(request.locationId()); + lock.setBusinessType(request.businessType()); + lock.setBusinessId(request.businessId()); + lock.setReservationKey(request.reservationKey()); + lock.setStatus("ACTIVE"); + lock.setLockedAt(LocalDateTime.now()); + repository.insert(lock); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse release(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "RELEASED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("RELEASED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehouseResourceLockResponse scrap(long customerId, long lockId) { + WarehouseResourceLockEntity lock = requireLock(customerId, lockId); + if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock); + requireActive(lock); + boolean virtualLocation = isVirtualLockLocation(lock); + int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock); + if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1) + || repository.close(lock, "SCRAPPED") != 1) { + throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently"); + } + lock.setStatus("SCRAPPED"); + lock.setReleasedAt(LocalDateTime.now()); + return WarehouseResourceLockResponse.from(lock); + } + + @Transactional + public WarehousePalletScrapResponse scrapByPallet(long customerId, long palletId) { + WarehousePalletEntity pallet = repository.findPallet(customerId, palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found"); + if ("SCRAPPED".equals(pallet.getStatus())) { + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + if (!"OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment"); + } + WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId); + if (lock != null) { + scrap(customerId, lock.getId()); + } else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) { + throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently"); + } + return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED"); + } + + private WarehouseResourceLockResponse concurrentIdempotentOrConflict( + WarehouseResourceLockRequests.ReserveRequest request) { + WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey()); + if (existing != null) return idempotentResult(existing, request); + throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE"); + } + + private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing, + WarehouseResourceLockRequests.ReserveRequest request) { + if (existing.getWarehouseId() != request.warehouseId() + || existing.getPalletId() != request.palletId() + || existing.getLocationId() != request.locationId() + || !Objects.equals(existing.getBusinessType(), request.businessType()) + || !Objects.equals(existing.getBusinessId(), request.businessId())) { + throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources"); + } + return WarehouseResourceLockResponse.from(existing); + } + + private WarehouseResourceLockEntity requireLock(long customerId, long lockId) { + WarehouseResourceLockEntity lock = repository.find(customerId, lockId); + if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found"); + return lock; + } + + private static void requireActive(WarehouseResourceLockEntity lock) { + if (!"ACTIVE".equals(lock.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE"); + } + } + + + private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) { + WarehouseLocationEntity location = repository.findLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found"); + return isVirtual(location); + } + + private static boolean isVirtual(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()); + } +} 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 new file mode 100644 index 000000000..b71345643 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -0,0 +1,282 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.Resource; + +import java.util.Set; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import org.springframework.util.StringUtils; + +@Service +public class WarehouseResourceService { + private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); + private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); + + @Resource private WarehouseResourceRepository repository; + + @Transactional + public Long createLocation(long customerId, long warehouseId, LocationRequests.SaveRequest request) { + requireWarehouse(customerId, warehouseId); + validateLocation(request, customerId, warehouseId, null); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + repository.insertLocation(entity); + return entity.getId(); + } + + @Transactional + public void updateLocation(long customerId, long warehouseId, long id, LocationRequests.SaveRequest request) { + WarehouseLocationEntity existing = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(existing.getLocationType()) + && (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status()) + || !existing.getLocationCode().equals(request.locationCode()))) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE"); + } + validateLocation(request, customerId, warehouseId, id); + WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request); + entity.setId(existing.getId()); + repository.updateLocation(entity); + } + + public WarehouseLocationEntity location(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requireLocation(customerId, warehouseId, id); + } + + public PageResult locations(long customerId, long warehouseId, + LocationRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pageLocations(customerId, warehouseId, query); + } + + @Transactional + public void deleteLocation(long customerId, long warehouseId, long id) { + WarehouseLocationEntity location = requireLocation(customerId, warehouseId, id); + if ("VIRTUAL".equals(location.getLocationType())) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted"); + } + if (repository.locationHasPallets(customerId, warehouseId, id)) { + throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除"); + } + repository.deleteLocation(id); + } + + @Transactional + public Long createPallet(long customerId, long warehouseId, PalletRequests.SaveRequest request) { + WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId); + validatePalletStatus(request.status()); + Long palletId = IdWorker.getId(); + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : generatedPalletCode(warehouse.getWarehouseCode(), palletId); + Long locationId = request.locationId(); + if (locationId == null) { + locationId = ensureDefaultVirtualLocation(customerId, warehouseId).getId(); + } + validatePallet(request, palletCode, locationId, customerId, warehouseId, null); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(palletId); + repository.insertPallet(entity); + return entity.getId(); + } + + @Transactional + public WarehousePalletEntity ensureDropshipPallet(long customerId, long warehouseId, String businessId) { + WarehousePalletBindingEntity existing = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (existing != null) return requirePallet(customerId, warehouseId, existing.getPalletId()); + Long palletId = createPallet(customerId, warehouseId, + new PalletRequests.SaveRequest(null, "DROPSHIP", null, java.math.BigDecimal.ZERO, "IDLE", null, + "Default dropship pallet for " + businessId)); + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setId(IdWorker.getId()); binding.setCustomerId(customerId); binding.setWarehouseId(warehouseId); + binding.setBusinessType("DROPSHIP"); binding.setBusinessId(businessId); binding.setPalletId(palletId); + binding.setCreatedAt(java.time.LocalDateTime.now()); + try { + repository.insertPalletBinding(binding); + return requirePallet(customerId, warehouseId, palletId); + } catch (org.springframework.dao.DuplicateKeyException duplicate) { + WarehousePalletBindingEntity winner = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId); + if (winner == null) throw duplicate; + return requirePallet(customerId, warehouseId, winner.getPalletId()); + } + } + + /** + * Returns the warehouse-scoped virtual location used only as a pallet/inventory reference. + * It is deliberately never made IDLE/OCCUPIED by resource locking. + */ + @Transactional + public WarehouseLocationEntity ensureDefaultVirtualLocation(long customerId, long warehouseId) { + WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId); + String locationCode = defaultLocationCode(warehouse.getWarehouseCode()); + WarehouseLocationEntity existing = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (existing != null) { + if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Default virtual location code is occupied by a non-active virtual location"); + } + return existing; + } + + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(IdWorker.getId()); + location.setCustomerId(customerId); + location.setWarehouseId(warehouseId); + location.setZoneCode("DEFAULT"); + location.setLocationCode(locationCode); + location.setLocationType("VIRTUAL"); + location.setMaxLoadKg(new java.math.BigDecimal("999999999.000")); + location.setStatus("ACTIVE"); + location.setRemark("System default virtual location"); + repository.insertDefaultVirtualLocationIgnore(location); + + WarehouseLocationEntity resolved = repository.findLocationByCode(customerId, warehouseId, locationCode); + if (resolved == null) { + throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created"); + } + return resolved; + } + + @Transactional + public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) { + WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id); + if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) { + throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused"); + } + String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim() + : existing.getPalletCode(); + Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId(); + validatePallet(request, palletCode, locationId, customerId, warehouseId, id); + WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId); + entity.setId(existing.getId()); + repository.updatePallet(entity); + } + + public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + return requirePallet(customerId, warehouseId, id); + } + + public PageResult pallets(long customerId, long warehouseId, + PalletRequests.QueryRequest query) { + requireWarehouse(customerId, warehouseId); + return repository.pagePallets(customerId, warehouseId, query); + } + + @Transactional + public void deletePallet(long customerId, long warehouseId, long id) { + WarehousePalletEntity pallet = requirePallet(customerId, warehouseId, id); + if ("OCCUPIED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除"); + } + if ("SCRAPPED".equals(pallet.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused"); + } + repository.deletePallet(id); + } + + private void requireWarehouse(long customerId, long warehouseId) { + if (!repository.warehouseOwned(customerId, warehouseId)) { + throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在"); + } + } + + private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) { + WarehouseEntity warehouse = repository.findWarehouse(customerId, warehouseId); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found"); + } + return warehouse; + } + + private WarehouseLocationEntity requireLocation(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehouseLocationEntity entity = repository.findLocation(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在"); + return entity; + } + + private WarehousePalletEntity requirePallet(long customerId, long warehouseId, long id) { + requireWarehouse(customerId, warehouseId); + WarehousePalletEntity entity = repository.findPallet(customerId, warehouseId, id); + if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在"); + return entity; + } + + private void validateLocation(LocationRequests.SaveRequest request, long customerId, + long warehouseId, Long excludedId) { + if (!LOCATION_STATUSES.contains(request.status())) { + throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE"); + } + if (repository.locationCodeExists(customerId, warehouseId, request.locationCode(), excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在"); + } + } + + private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId, + long customerId, long warehouseId, Long excludedId) { + validatePalletStatus(request.status()); + if (repository.palletCodeExists(customerId, warehouseId, palletCode, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在"); + } + if (locationId != null) { + requireLocation(customerId, warehouseId, locationId); + } + } + + private void validatePalletStatus(String status) { + if (!PALLET_STATUSES.contains(status)) { + throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED"); + } + } + + private WarehouseLocationEntity locationEntity(long customerId, long warehouseId, + LocationRequests.SaveRequest request) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setZoneCode(request.zoneCode()); + entity.setLocationCode(request.locationCode()); + entity.setLocationType(request.locationType()); + entity.setMaxLoadKg(request.maxLoadKg()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private WarehousePalletEntity palletEntity(long customerId, long warehouseId, + PalletRequests.SaveRequest request, + String palletCode, Long locationId) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setCustomerId(customerId); + entity.setWarehouseId(warehouseId); + entity.setLocationId(locationId); + entity.setPalletCode(palletCode); + entity.setPalletType(request.palletType()); + entity.setCapacityCbm(request.capacityCbm()); + entity.setUnitPrice(request.unitPrice()); + entity.setStatus(request.status()); + entity.setRemark(request.remark()); + return entity; + } + + private static String generatedPalletCode(String warehouseCode, long palletId) { + return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + Long.toUnsignedString(palletId); + } + + private static String defaultLocationCode(String warehouseCode) { + return "LOC-" + warehouseCode + "-DEFAULT"; + } +} diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml new file mode 100644 index 000000000..7b84bd908 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -0,0 +1,45 @@ +server: + port: ${WAREHOUSE_PORT:9305} +spring: + sql: + init: + mode: always + encoding: UTF-8 + schema-locations: classpath:db/warehouse.sql + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: warehouse-overseas-service + config: + import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + cloud: + nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + enabled: ${NACOS_DISCOVERY_ENABLED:true} + register-enabled: ${NACOS_REGISTER_ENABLED:true} + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:public} + config.namespace: ${NACOS_NAMESPACE:public} + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:} +management.endpoints.web.exposure.include: health,info,prometheus + +baiyue: + internal-service: + token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + security: + jwt-mode: jwks + session-mode: claims + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql new file mode 100644 index 000000000..7d0cb217e --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -0,0 +1,259 @@ +-- This script is run through warehouse-overseas-service's datasource only. +-- The datasource guard requires the selected schema to be baiyue_warehouse_overseas; +-- database creation and USE are intentionally forbidden here so a miswired +-- service account cannot create or switch schemas during startup. + +CREATE TABLE IF NOT EXISTS wh_location ( + id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_location_code (customer_id, warehouse_id, location_code, deleted), + KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted) +) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; + +CREATE TABLE IF NOT EXISTS wh_pallet ( + id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空', + pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_pallet_code (customer_id, warehouse_id, pallet_code, deleted), + KEY idx_pallet_query (customer_id, warehouse_id, status, deleted), + KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted) +) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; + +CREATE TABLE IF NOT EXISTS wh_resource_lock ( + id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID', + pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID', + location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID', + business_type VARCHAR(50) NOT NULL COMMENT '业务类型', + business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID', + reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) DEFAULT 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_resource_lock_reservation (customer_id, reservation_key), + KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status), + KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status), + KEY idx_resource_lock_business (customer_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='仓储资源锁定记录'; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', + contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_tenant_code (customer_id, warehouse_code, deleted), + KEY idx_wh_tenant_status (customer_id, status, deleted), + KEY idx_wh_country (customer_id, country_code, deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', + district VARCHAR(100) DEFAULT NULL COMMENT '区县', + postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', + longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_address_type (customer_id, warehouse_id, address_type, deleted), + KEY idx_address_warehouse (customer_id, warehouse_id, deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间', + description VARCHAR(500) DEFAULT NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_capability (customer_id, warehouse_id, capability_code, deleted), + KEY idx_capability_query (customer_id, warehouse_id, enabled, deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 customer_id 与 warehouse_id 共同校验。 + +-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。 +ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息'; +ALTER TABLE wh_location + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码', + MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码', + MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型', + MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID', + business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID', + pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id), + UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) +) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; + +-- Every warehouse has one non-occupiable virtual location used for inventory history. +INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type, + max_load_kg, status, remark) +SELECT UUID_SHORT(), w.customer_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), + 'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location' +FROM wh_warehouse w +WHERE NOT EXISTS ( + SELECT 1 FROM wh_location l + WHERE l.customer_id = w.customer_id AND l.warehouse_id = w.id + AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0 +); + +ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +ALTER TABLE wh_pallet + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空', + MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码', + MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型', + MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)', + MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; +ALTER TABLE wh_warehouse + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人', + MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话', + MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; +ALTER TABLE wh_warehouse_address + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货', + MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码', + MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州', + MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市', + MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县', + MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码', + MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行', + MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度', + MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; + +ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; +ALTER TABLE wh_service_capability + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货', + MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间', + MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明', + MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'; diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java new file mode 100644 index 000000000..b075a3780 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WarehouseDatabaseGuardConfigurationTest { + @Test + void acceptsDedicatedOverseasWarehouseDatabase() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", + "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?serverTimezone=Asia/Shanghai"); + + assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .doesNotThrowAnyException(); + } + + @Test + void rejectsAnyOtherDatabaseBeforeSchemaInitialization() { + MockEnvironment environment = new MockEnvironment() + .withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse"); + + assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("baiyue_warehouse_overseas"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java new file mode 100644 index 000000000..4c1f680e1 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -0,0 +1,69 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.service.WarehouseResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class WarehouseResourceControllerTest { + private final WarehouseResourceService service = mock(WarehouseResourceService.class); + + @Test + void merchantTenantAlwaysComesFromAuthenticatedUser() { + MerchantWarehouseResourceController controller = merchantController(); + AuthenticatedUser merchant = user(10001L, "MERCHANT"); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10001L, 20001L, 30001L)).thenReturn(expected); + + controller.location(merchant, 20001L, 30001L); + + verify(service).location(10001L, 20001L, 30001L); + } + + @Test + void platformApiRejectsNonPlatformIdentity() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), + 10002L, 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); + } + + @Test + void platformApiUsesExplicitTargetTenant() { + PlatformWarehouseResourceController controller = platformController(); + WarehouseLocationEntity expected = new WarehouseLocationEntity(); + when(service.location(10002L, 20001L, 30001L)).thenReturn(expected); + + controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L); + + verify(service).location(10002L, 20001L, 30001L); + } + + private static AuthenticatedUser user(long customerId, String appCode) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "tester", appCode, "WEB", Set.of()); + } + + private MerchantWarehouseResourceController merchantController() { + MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } + + private PlatformWarehouseResourceController platformController() { + PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController(); + ReflectionTestUtils.setField(controller, "service", service); + return controller; + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java new file mode 100644 index 000000000..f59b0735b --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -0,0 +1,98 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SchemaChineseCommentContractTest { + + private static final Pattern TABLE = Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)" + + "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;"); + private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]"); + + @Test + void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8); + String incremental = Files.readString( + Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + + List tables = parseAndAssert(baseline, "仓储基线 SQL"); + assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); + parseAndAssert(incremental, "统一增量 SQL"); + + int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); + assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移"); + String migration = incremental.substring(marker); + for (TableDefinition table : tables) { + assertTrue(migration.contains("table_name='" + table.name() + "'"), + "025 备注迁移缺少表: " + table.name()); + for (String column : table.columns()) { + assertTrue(migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"), + "025 备注迁移缺少字段: " + table.name() + "." + column); + } + } + } + + private static List parseAndAssert(String sql, String source) { + List tables = new ArrayList<>(); + Matcher matcher = TABLE.matcher(sql); + while (matcher.find()) { + String table = matcher.group(1); + assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table); + List columns = new ArrayList<>(); + for (String definition : splitTopLevel(matcher.group(2))) { + String normalized = definition.stripLeading().toUpperCase(); + if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) { + continue; + } + String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", ""); + columns.add(column); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'") + .matcher(definition).find(), + source + " 字段备注必须包含中文: " + table + "." + column); + } + assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table); + tables.add(new TableDefinition(table, columns)); + } + assertFalse(tables.isEmpty(), source + " 未解析到仓储表"); + return tables; + } + + private static List splitTopLevel(String body) { + List definitions = new ArrayList<>(); + int start = 0; + int parentheses = 0; + boolean quoted = false; + for (int i = 0; i < body.length(); i++) { + char current = body.charAt(i); + if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) { + quoted = !quoted; + } else if (!quoted && current == '(') { + parentheses++; + } else if (!quoted && current == ')') { + parentheses--; + } else if (!quoted && parentheses == 0 && current == ',') { + definitions.add(body.substring(start, i).trim()); + start = i + 1; + } + } + definitions.add(body.substring(start).trim()); + return definitions.stream().filter(value -> !value.isEmpty()).toList(); + } + + private record TableDefinition(String name, List columns) { + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java new file mode 100644 index 000000000..639f8cfb7 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehouseResourceLockSchemaTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseResourceLockSchemaTest { + @Test + void schemaContainsIdempotentResourceLockContract() throws IOException { + String sql; + try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) { + assertThat(stream).isNotNull(); + sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock"); + assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key)"); + assertThat(sql).contains("KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status)"); + assertThat(sql).contains("KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status)"); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java new file mode 100644 index 000000000..0f957134e --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/security/InternalServiceTokenGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.security; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InternalServiceTokenGuardTest { + private final InternalServiceTokenGuard guard = + new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes"); + + @Test + void acceptsConfiguredToken() { + assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes")) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingOrWrongToken() { + assertUnauthorized(null); + assertUnauthorized("wrong"); + } + + private void assertUnauthorized(String token) { + assertThatThrownBy(() -> guard.verify(token)) + .isInstanceOfSatisfying(ResponseStatusException.class, + error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java new file mode 100644 index 000000000..8fce21255 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class WarehouseApplicationServiceTest { + @Mock WarehouseRepository repository; + private WarehouseApplicationService service; + + @BeforeEach + void setUp() { + service = new WarehouseApplicationService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() { + doAnswer(call -> { + ((WarehouseEntity) call.getArgument(0)).setId(20001L); + return null; + }).when(repository).insertWarehouse(any()); + + service.create(request()); + + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(location.capture()); + assertThat(location.getValue().getCustomerId()).isEqualTo(10001L); + assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + private static WarehouseRequests.SaveRequest request() { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, + "ACTIVE", null, address, List.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java new file mode 100644 index 000000000..a5420b4ba --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceLockServiceTest.java @@ -0,0 +1,263 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceLockServiceTest { + private static final long TENANT = 101L; + private static final long WAREHOUSE = 201L; + private static final long PALLET = 301L; + private static final long LOCATION = 401L; + + @Mock WarehouseResourceLockRepository repository; + private WarehouseResourceLockService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceLockService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void atomicallyOccupiesIdlePalletAndLocation() { + var request = request("reserve-1"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L); + return null; + }).when(repository).insert(any()); + + var result = service.reserve(request); + + assertThat(result.lockId()).isEqualTo(501L); + assertThat(result.status()).isEqualTo("ACTIVE"); + ArgumentCaptor lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class); + verify(repository).insert(lock.capture()); + assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1"); + assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET); + assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION); + } + + @Test + void duplicateReservationReturnsExistingLockWithoutMutatingResources() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + verify(repository, never()).insert(any()); + } + + @Test + void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() { + WarehouseResourceLockEntity existing = activeLock(); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + + var result = service.reserve(request("reserve-1")); + + assertThat(result.lockId()).isEqualTo(existing.getId()); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void reservationKeyCannotBeReusedForDifferentResources() { + WarehouseResourceLockEntity existing = activeLock(); + existing.setPalletId(999L); + when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different resources"); + } + + @Test + void rejectsResourceOutsideTenantWarehouse() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("tenant warehouse"); + verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION); + } + + @Test + void rejectsNonIdleResources() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("Only IDLE"); + } + + @Test + void releaseRestoresBothResourcesAndClosesLock() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.releasePallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "RELEASED")).thenReturn(1); + + var result = service.release(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("RELEASED"); + verify(repository).releasePallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void scrapDiscardsPalletAndReleasesLocation() { + WarehouseResourceLockEntity lock = activeLock(); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED")); + when(repository.scrapPallet(lock)).thenReturn(1); + when(repository.releaseLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPallet(lock); + verify(repository).releaseLocation(lock); + } + + @Test + void concurrentLocationClaimFailsWholeOperation() { + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE")); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0); + + assertThatThrownBy(() -> service.reserve(request("reserve-1"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("no longer IDLE"); + verify(repository, never()).insert(any()); + } + + @Test + void virtualLocationIsNeverOccupiedOrReleased() { + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE")); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1); + doAnswer(call -> { + ((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L); + return null; + }).when(repository).insert(any()); + + service.reserve(request("virtual-reserve")); + + verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION); + } + + @Test + void scrappingAtVirtualLocationKeepsLocationUntouched() { + WarehouseResourceLockEntity lock = activeLock(); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrap(TENANT, lock.getId()); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + verify(repository).scrapPalletAtVirtualLocation(lock); + verify(repository, never()).releaseLocation(lock); + } + + @Test + void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() { + WarehousePalletEntity pallet = pallet("OCCUPIED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + WarehouseResourceLockEntity lock = activeLock(); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock); + when(repository.find(TENANT, lock.getId())).thenReturn(lock); + WarehouseLocationEntity virtual = location("ACTIVE"); + virtual.setLocationType("VIRTUAL"); + when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual); + when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1); + when(repository.close(lock, "SCRAPPED")).thenReturn(1); + + var result = service.scrapByPallet(TENANT, PALLET); + + assertThat(result.status()).isEqualTo("SCRAPPED"); + assertThat(result.locationId()).isEqualTo(LOCATION); + verify(repository).scrapPalletAtVirtualLocation(lock); + } + + @Test + void scrapByPalletIsIdempotentWhenAlreadyScrapped() { + WarehousePalletEntity pallet = pallet("SCRAPPED"); + pallet.setWarehouseId(WAREHOUSE); + pallet.setLocationId(LOCATION); + when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet); + + assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED"); + verify(repository, never()).findActiveByPallet(TENANT, PALLET); + } + + private static WarehouseResourceLockRequests.ReserveRequest request(String key) { + return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION, + "INBOUND_PLAN_PALLET", "plan-pallet-1", key); + } + + private static WarehousePalletEntity pallet(String status) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseLocationEntity location(String status) { + WarehouseLocationEntity entity = new WarehouseLocationEntity(); + entity.setStatus(status); + return entity; + } + + private static WarehouseResourceLockEntity activeLock() { + WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity(); + lock.setId(501L); + lock.setCustomerId(TENANT); + lock.setWarehouseId(WAREHOUSE); + lock.setPalletId(PALLET); + lock.setLocationId(LOCATION); + lock.setBusinessType("INBOUND_PLAN_PALLET"); + lock.setBusinessId("plan-pallet-1"); + lock.setReservationKey("reserve-1"); + lock.setStatus("ACTIVE"); + return lock; + } +} 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 new file mode 100644 index 000000000..778edcddc --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -0,0 +1,260 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.LocationRequests; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseResourceServiceTest { + private static final long TENANT = 10001L; + private static final long WAREHOUSE = 20001L; + + @Mock + private WarehouseResourceRepository repository; + + private WarehouseResourceService service; + + @BeforeEach + void setUp() { + service = new WarehouseResourceService(); + ReflectionTestUtils.setField(service, "repository", repository); + } + + @Test + void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException { + assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type()) + .isEqualTo(IdType.ASSIGN_ID); + } + + @Test + void createsLocationWithSnowflakeAssignedByMapper() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + doAnswer(invocation -> { + ((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L); + return null; + }).when(repository).insertLocation(org.mockito.ArgumentMatchers.any()); + + Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")); + + assertThat(id).isEqualTo(2089000000000000001L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertLocation(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isEqualTo(TENANT); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + + @Test + void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("仓库不存在"); + verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any()); + } + + @Test + void rejectsDuplicateLocationCodeWithinWarehouse() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true); + + assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位编码已存在"); + } + + @Test + void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location); + doAnswer(invocation -> { + ((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L); + return null; + }).when(repository).insertPallet(org.mockito.ArgumentMatchers.any()); + + Long id = service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + new BigDecimal("12.50"), "IDLE", 30001L, null)); + + assertThat(id).isEqualTo(2089000000000000002L); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30001L); + } + + @Test + void rejectsLocationFromAnotherWarehouseWhenBindingPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "IDLE", 30002L, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("库位不存在"); + verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any()); + } + + @Test + void preventsDeletingLocationThatStillHasPallets() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity()); + when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true); + + assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("仍绑定托盘"); + verify(repository, never()).deleteLocation(30001L); + } + + @Test + void preventsDeletingOccupiedPallet() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("OCCUPIED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("占用中的托盘"); + verify(repository, never()).deletePallet(40001L); + } + + @Test + void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity pallet = new WarehousePalletEntity(); + pallet.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet); + + assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + verify(repository, never()).deletePallet(40003L); + } + + @Test + void rejectsUnknownPalletStatus() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + + assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest("PLT-01", "STANDARD", null, + BigDecimal.ONE, "BROKEN", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("IDLE、OCCUPIED、SCRAPPED"); + } + + private static LocationRequests.SaveRequest locationRequest(String code) { + return new LocationRequests.SaveRequest("A", code, "STORAGE", + new BigDecimal("1000.000"), "ACTIVE", null); + } + + @Test + void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30003L); + location.setLocationType("VIRTUAL"); + location.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(location); + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location); + + service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+"); + assertThat(captor.getValue().getLocationId()).isEqualTo(30003L); + assertThat(captor.getValue().getId()).isNotNull(); + } + + @Test + void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity resolved = new WarehouseLocationEntity(); + resolved.setId(30004L); + resolved.setLocationType("VIRTUAL"); + resolved.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(null, resolved); + + WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE); + + assertThat(result.getId()).isEqualTo(30004L); + ArgumentCaptor location = ArgumentCaptor.forClass(WarehouseLocationEntity.class); + verify(repository).insertDefaultVirtualLocationIgnore(location.capture()); + assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT"); + assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL"); + assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE"); + } + + @Test + void rejectsAConflictingDefaultLocationCode() { + when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse()); + WarehouseLocationEntity conflict = new WarehouseLocationEntity(); + conflict.setLocationType("STORAGE"); + conflict.setStatus("ACTIVE"); + when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT")) + .thenReturn(conflict); + + assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Default virtual location code"); + verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any()); + } + + @Test + void scrappedPalletCannotBeReactivated() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40002L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("SCRAPPED"); + when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null))) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be reused"); + } + + private static WarehouseEntity warehouse() { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setId(WAREHOUSE); + warehouse.setCustomerId(TENANT); + warehouse.setWarehouseCode("US-LAX-01"); + return warehouse; + } +} From 3b8934fc60a0ca4f7a911a2a62ddb3f2d2b3239c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 19 Aug 2026 16:45:44 +0800 Subject: [PATCH 007/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/AGENTS.md | 6 ++++-- integration-service/AGENTS.md | 6 ++++-- warehouse/AGENTS.md | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index bb1b71671..6a6e0ec51 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计及国内出仓履约链路。 +- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计、国内出仓履约链路,以及四期国内出仓后的船节点查询任务和节点事实。 - 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 - 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 @@ -28,7 +28,8 @@ | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 四期国内出仓后船节点定时查询 | 现有 `portschedule/controller/PlatformCarrierScheduleQueryController.java`、`service/CarrierScheduleQueryService.java` 及查询任务、尝试、历史整体归四期;新增定时编排、节点事实或到港衔接继续归该子域,外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | @@ -48,6 +49,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史,并以幂等方式把确认到港/可提柜结果衔接给海外区域。现有 `CarrierScheduleQuery` 及“每 3 天一次、窗口最多 3 次”约束属于四期;外部失败不得推进业务节点,不得在三期另建查询任务。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index b7e48949b..72926fad5 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责百度 OCR、Amazon Logistics/SP-API 等第三方适配和能力开关;不保存业务主数据。 +- 负责百度 OCR、Amazon Logistics/SP-API、四期船司/船舶轨迹查询等第三方适配和能力开关;不保存业务主数据。 - 二期重量 OCR 由 `file/` 调用;本服务只返回识别结果,不保存 `file_object`、入库单或重量确认记录。 ## 模块入口与验证 @@ -11,11 +11,13 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` +- 四期船节点外部适配:继续进入 `provider/` 和内部接口层;轮询任务、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` ## 修改约束与分期 - 密钥、原始响应和客户敏感数据不得写入日志;真实调用默认关闭,适配器负责超时、重试、限流、错误归一化。 +- 船节点适配器不得自行维护业务轮询任务或到港状态;必须返回可审计的来源、观测时间、标准化节点和错误结果,由 Firstleg 负责幂等落库与停止条件。查询周期和供应商额度通过配置表达,不硬编码复用三期人工查询限制。 - OCR 只能访问受控输入/输出目录,接口仅接受内部服务令牌;契约变更同步检查 File 的 `WeightOcrClient` 和测试。 -- 全量验收按业务链路核对通用适配、File 重量 OCR、船期外部查询及已确认的报关协作契约;外部调用必须保留来源、频率、限流、超时、最近有效结果和过期标识,凭证缺失按既定人工修正降级,不把未确认的自动化能力当作已实现契约。相关依据:`../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/`。 +- 全量验收按业务链路核对通用适配、File 重量 OCR 及已确认的报关协作契约;柜子确认国内出仓后的船司/船节点外部查询整体归四期,不再列作三期能力。外部调用必须保留来源、频率、限流、超时、最近有效结果和过期标识,凭证缺失按既定人工修正降级,不把未确认的自动化能力当作已实现契约。相关依据:`../../docs/一期/`、`../../docs/二期/`、`../../docs/三期/`、`../../docs/四期/`。 diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index d20a1b119..c888270b4 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -2,8 +2,9 @@ ## 职责与边界 -- 负责国内收货仓、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。 +- 负责国内收货仓及现有平台仓库主数据、地址/能力、往来单位、快递公司、平台公共供应商、国内整柜库存/出入库,以及托盘主数据、库位预占和占用。四期只在本模块扩展“海外仓合作仓”仓库类型及其公共主数据契约。 - 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。 +- 四期海外接柜、卸货、海外入库、换标、产品、箱号、库存和库内作业属于海外区域服务,不得落入本国内 Warehouse Service;范围先读 `../../docs/四期/README.md`。 - 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。 ## 模块入口 @@ -11,6 +12,7 @@ | 目标 | 入口 | |---|---| | 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` | +| 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` | | 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` | | 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` | From 63618c20dc2a09abd56dac17baefbec9b1a53cea Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 16:49:41 +0800 Subject: [PATCH 008/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 5 ++++- scripts/db/incremental.sql | 10 +++++----- .../WarehouseDatabaseGuardConfiguration.java | 2 +- .../src/main/resources/application.yml | 4 ++-- .../src/main/resources/db/warehouse.sql | 2 +- ...rehouseDatabaseGuardConfigurationTest.java | 19 +++++++++++++++++-- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 053a362a3..bbeb5714a 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -19,15 +19,18 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + enabled: ${NACOS_CONFIG_ENABLED:true} namespace: ${NACOS_NAMESPACE:public} gateway: discovery: diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index ca5541703..8253ca30c 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -71,9 +71,9 @@ PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE mig -- Warehouse locations, pallets, resource locks and UTF-8 descriptions -- ============================================================================ -CREATE DATABASE IF NOT EXISTS baiyue_warehouse_overseas +CREATE DATABASE IF NOT EXISTS baiyue_lastlag_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_warehouse_overseas; +USE baiyue_lastlag_warehouse; CREATE TABLE IF NOT EXISTS wh_location ( id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)', @@ -520,7 +520,7 @@ PREPARE migration_stmt FROM @ddl; EXECUTE migration_stmt; DEALLOCATE PREPARE mig -- Warehouse master-data descriptions and query indexes -- ============================================================================ -USE baiyue_warehouse_overseas; +USE baiyue_lastlag_warehouse; CREATE TABLE IF NOT EXISTS wh_warehouse ( id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', @@ -752,7 +752,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( ) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; -- 2026-08-17 warehouse virtual location and generated pallet code support. -USE baiyue_warehouse_overseas; +USE baiyue_lastlag_warehouse; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='pallet_code'), @@ -3319,7 +3319,7 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; -USE baiyue_warehouse_overseas; +USE baiyue_lastlag_warehouse; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_location' AND COALESCE(table_comment,'') <> '仓库库位表:按租户和仓库维护库位及承重信息'), 'ALTER TABLE wh_location COMMENT = ''仓库库位表:按租户和仓库维护库位及承重信息''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java index 42efe4c60..90dc9b170 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -18,7 +18,7 @@ public class WarehouseDatabaseGuardConfiguration { static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) { String url = environment.getProperty("spring.datasource.url", "").trim(); Matcher matcher = MYSQL_SCHEMA.matcher(url); - if (!matcher.matches() || !"baiyue_warehouse_overseas".equalsIgnoreCase(matcher.group(1))) { + if (!matcher.matches() || !"baiyue_lastlag_warehouse".equalsIgnoreCase(matcher.group(1))) { throw new IllegalStateException( "warehouse-overseas-service requires spring.datasource.url to target the baiyue_warehouse_overseas database"); } diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 7b84bd908..a21ada6fe 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -29,9 +29,9 @@ spring: config.namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:} + password: ${WAREHOUSE_DB_PASSWORD:123456} management.endpoints.web.exposure.include: health,info,prometheus baiyue: diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 7d0cb217e..109771014 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -1,5 +1,5 @@ -- This script is run through warehouse-overseas-service's datasource only. --- The datasource guard requires the selected schema to be baiyue_warehouse_overseas; +-- The datasource guard requires the selected schema to be baiyue_lastlag_warehouse; -- database creation and USE are intentionally forbidden here so a miswired -- service account cannot create or switch schemas during startup. diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java index b075a3780..d02337a6f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -1,17 +1,32 @@ package com.baiyue.warehouse.config; import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; import org.springframework.mock.env.MockEnvironment; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; class WarehouseDatabaseGuardConfigurationTest { + @Test + void applicationDefaultsTargetDedicatedOverseasWarehouseDatabase() throws Exception { + var properties = new YamlPropertySourceLoader() + .load("application", new ClassPathResource("application.yml")) + .get(0); + + assertThat(properties.getProperty("spring.datasource.url")) + .asString() + .contains("baiyue_lastlag_warehouse") + .doesNotContain("baiyue_lastlag_warehouse"); + } + @Test void acceptsDedicatedOverseasWarehouseDatabase() { MockEnvironment environment = new MockEnvironment() .withProperty("spring.datasource.url", - "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?serverTimezone=Asia/Shanghai"); + "jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?serverTimezone=Asia/Shanghai"); assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) .doesNotThrowAnyException(); @@ -24,6 +39,6 @@ class WarehouseDatabaseGuardConfigurationTest { assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment)) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("baiyue_warehouse_overseas"); + .hasMessageContaining("baiyue_lastlag_warehouse"); } } From f78b44297d874c9d78a8355793c367d014ddc0fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 19 Aug 2026 17:08:41 +0800 Subject: [PATCH 009/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 11 ++++++++--- gateway-overseas/AGENTS.md | 5 ++--- lastleg-service/AGENTS.md | 8 ++++++++ warehouse-overseas-service/AGENTS.md | 4 ++-- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cbee3f977..77c072cba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ -# 国内头程后端项目指引 +# 头程与海外仓后端项目指引 ## 适用范围与定位 -- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。 +- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 @@ -10,7 +10,7 @@ - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 -- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。 +- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 ## 服务路由(按关键词定位) @@ -32,6 +32,9 @@ | 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | +| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 | ## 全量验收路由 @@ -44,6 +47,7 @@ - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 - 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 +- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 ## 契约、数据库与验证入口 @@ -69,6 +73,7 @@ - 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md` - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 - 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 +- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。 diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 8d4aff60e..dfcc35264 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -1,4 +1,4 @@ -# Gateway 模块指引 +# Overseas Gateway 模块指引 ## 职责边界 @@ -25,6 +25,5 @@ ## 修改与验证 -- 定向验证:`mvn -pl gateway -am test`。 +- 定向验证:`mvn -pl gateway-overseas -am test`。 - 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。 - diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 7a71049e7..1d5999c8b 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -20,14 +20,22 @@ | 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` | +| 入仓后库存余额、库位和流水 | `inventory/`;平台库存命令从 `inventory/command/controller/InventoryCommandController.java` 进入 | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | | 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` | +| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | +## 四期开发范围路由 + +- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 +- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 +- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 + ## 数据库表索引与注释约束 | 业务域 | 表名 | 中文含义 | diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index e3b1498dc..f3e53ff47 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -1,4 +1,4 @@ -# Warehouse Service 模块指引 +# Overseas Warehouse Service 模块指引 ## 职责与边界 @@ -42,4 +42,4 @@ ## 修改与验证 - 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 -- 定向验证:`mvn -pl warehouse -am test`。 +- 定向验证:`mvn -pl warehouse-overseas-service -am test`。 From 26eba227ebbe83595bce506133b6b179be60f6bd Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 17:22:03 +0800 Subject: [PATCH 010/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/overseas/event-bridge-service.yml | 2 +- .../eventbridge/service/OverseasEventDispatcher.java | 2 +- event-bridge-service/src/main/resources/application.yml | 2 +- .../config/WarehouseDatabaseGuardConfiguration.java | 2 +- .../src/main/resources/application.yml | 9 ++++++--- .../config/WarehouseDatabaseGuardConfigurationTest.java | 2 +- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 9d911281c..610138e21 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,7 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 39317dbe1..76c1bf82f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -23,7 +23,7 @@ public class OverseasEventDispatcher { @Resource private EventDispatcherProperties properties; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); - @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}") + @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") public void dispatch() { for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) { dispatchOne(eventId); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index c2f16ae02..53a1bdc39 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -32,7 +32,7 @@ baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s} + fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java index 90dc9b170..5d115f267 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfiguration.java @@ -20,7 +20,7 @@ public class WarehouseDatabaseGuardConfiguration { Matcher matcher = MYSQL_SCHEMA.matcher(url); if (!matcher.matches() || !"baiyue_lastlag_warehouse".equalsIgnoreCase(matcher.group(1))) { throw new IllegalStateException( - "warehouse-overseas-service requires spring.datasource.url to target the baiyue_warehouse_overseas database"); + "warehouse-overseas-service requires spring.datasource.url to target the baiyue_lastlag_warehouse database"); } return beanFactory -> { }; } diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index a21ada6fe..0dd704ced 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -9,7 +9,7 @@ spring: data: redis: host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + port: ${REDIS_PORT:6379} application: name: warehouse-overseas-service config: @@ -18,15 +18,18 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + config: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java index d02337a6f..2f2ef9751 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -19,7 +19,7 @@ class WarehouseDatabaseGuardConfigurationTest { assertThat(properties.getProperty("spring.datasource.url")) .asString() .contains("baiyue_lastlag_warehouse") - .doesNotContain("baiyue_lastlag_warehouse"); + .doesNotContain("baiyue_warehouse_overseas"); } @Test From f03ea227ed00be03c680bccfd0759208b04ea78d Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 19 Aug 2026 17:23:54 +0800 Subject: [PATCH 011/231] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 0dd704ced..1980faedf 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -9,7 +9,7 @@ spring: data: redis: host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} + port: ${REDIS_PORT:6380} application: name: warehouse-overseas-service config: @@ -18,17 +18,17 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver From f227cf05a33ff74a6cb300f865b633984873efbb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 09:10:30 +0800 Subject: [PATCH 012/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + auth/AGENTS.md | 1 + .../com/baiyue/auth/dto/TokenResponse.java | 8 +++- .../com/baiyue/auth/service/AuthService.java | 10 ++++- .../auth/service/OverseasTokenService.java | 12 +++-- .../auth/service/AuthServiceSmallJwtTest.java | 45 +++++++++++++++++-- .../service/OverseasTokenServiceTest.java | 8 ++-- deploy/sql/头程数据库统一增量-MySQL8.sql | 36 ++++++++++----- ...latformCarrierScheduleQueryController.java | 6 +-- .../PlatformPortScheduleController.java | 4 +- .../phase3-carrier-schedule-query-schema.sql | 18 +++++--- .../db/phase3-port-schedule-schema.sql | 14 ++++-- .../openapi/carrier-schedule-query-v1.yaml | 7 +++ .../resources/openapi/port-schedules-v1.yaml | 5 +++ ...erScheduleQueryControllerContractTest.java | 6 +-- ...ormPortScheduleControllerContractTest.java | 4 +- .../PortScheduleSqlContractTest.java | 6 ++- gateway-overseas/AGENTS.md | 1 + iam/AGENTS.md | 1 + .../InternalWarehouseController.java | 9 ++++ .../dto/OverseasPartnerWarehouseResponse.java | 17 +++++++ .../warehouse/dto/WarehouseRequests.java | 8 ++-- .../dto/WarehouseSummaryResponse.java | 2 +- .../warehouse/entity/WarehouseEntity.java | 3 +- .../repository/WarehouseRepository.java | 9 ++++ .../service/WarehouseApplicationService.java | 9 ++++ .../service/WarehouseImportService.java | 6 +-- .../service/WarehouseSaveNormalizer.java | 7 ++- warehouse/src/main/resources/db/warehouse.sql | 2 +- .../InternalWarehouseControllerTest.java | 12 +++++ .../WarehouseApplicationServiceTest.java | 33 ++++++++++++++ .../service/WarehouseImportServiceTest.java | 16 +++++++ 32 files changed, 268 insertions(+), 58 deletions(-) create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java diff --git a/AGENTS.md b/AGENTS.md index 77c072cba..e7699abfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 +- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 ## 不可突破的产品与数据边界 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index bc91720f2..46d7abdee 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -4,6 +4,7 @@ - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java index eeb8d43ca..ac2206a4d 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -11,6 +11,10 @@ public record TokenResponse( @Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken, @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, - @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn + @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, + @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", + format = "password", nullable = true) String overseasAccessToken, + @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", + example = "900", minimum = "0") long overseasExpiresIn ) { -} \ No newline at end of file +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 72a8dab9d..ec283c23e 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.beans.factory.ObjectProvider; import java.security.SecureRandom; import java.time.Instant; @@ -49,6 +50,8 @@ public class AuthService { private AuthorizationLoader authorizationLoader; @Resource private TokenSessionStore tokenSessionStore; + @Resource + private ObjectProvider overseasTokens; private final SecureRandom secureRandom = new SecureRandom(); @@ -134,8 +137,13 @@ public class AuthService { String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); + OverseasTokenService service = overseasTokens.getIfAvailable(); + OverseasTokenService.OverseasTokenResponse overseas = service == null + ? null : service.issue(account, authorization); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), - rawRefresh, properties.refreshTokenTtl().toSeconds()); + rawRefresh, properties.refreshTokenTtl().toSeconds(), + overseas == null ? null : overseas.accessToken(), + overseas == null ? 0L : overseas.expiresIn()); } @Transactional 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 0f7eb256a..2da6a5e9c 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -36,16 +36,22 @@ public class OverseasTokenService { throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); } AuthorizationSnapshot authorization = authorizationLoader.load(account); + return issue(account, authorization); + } + + public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId()) - .claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles()) + .claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId()) + .claim("membership_id", authorization.getMembershipId()) + .claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType()) + .claim("roles", authorization.getRoles()) .claim("authorities", authorization.getPermissions()) - .claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) .build(); String token = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); 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 0d8e3851b..aed82fc6f 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -34,6 +34,7 @@ class AuthServiceSmallJwtTest { @MockBean private JwtEncoder jwtEncoder; @MockBean private AuthorizationLoader authorizationLoader; @MockBean private TokenSessionStore tokenSessionStore; + @MockBean private OverseasTokenService overseasTokenService; @Resource private AuthService service; @Configuration(proxyBeanMethods = false) @@ -59,7 +60,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list")); + "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -67,8 +68,10 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-token"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900)); - service.login(new LoginRequest("operator", "secret")); + var response = service.login(new LoginRequest("operator", "secret")); ArgumentCaptor jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class); verify(jwtEncoder).encode(jwt.capture()); @@ -88,6 +91,42 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); + verify(overseasTokenService).issue(account, snapshot); + assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + } + + @Test + void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + AuthAccount account = new AuthAccount(); + account.setId(52L); + account.setUsername("merchant"); + account.setAccountStatus("ACTIVE"); + account.setDefaultTenantId(10001L); + account.setOperatorTenantId(10001L); + account.setDefaultMembershipId(94L); + account.setDefaultAppCode("MERCHANT"); + account.setTokenVersion(3); + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT", + "WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L); + Jwt encoded = org.mockito.Mockito.mock(Jwt.class); + + when(tokenSessionStore.consume("refresh-old")).thenReturn( + new TokenSessionStore.RefreshSession(52L, "refresh-session")); + when(repository.findById(52L)).thenReturn(account); + when(authorizationLoader.load(account)).thenReturn(snapshot); + when(jwtEncoder.encode(any())).thenReturn(encoded); + when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); + when(overseasTokenService.issue(account, snapshot)).thenReturn( + new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900)); + + var response = service.refresh("refresh-old"); + + assertThat(response.accessToken()).isEqualTo("domestic-refreshed"); + assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); + assertThat(response.overseasExpiresIn()).isEqualTo(900); + verify(authorizationLoader).load(account); + verify(overseasTokenService).issue(account, snapshot); } @Test @@ -102,7 +141,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("MERCHANT"); account.setTokenVersion(2); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT", - "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list")); + "WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket( "ticket-1", 43L, 10001L, 85L, 42L, 84L, "support"); 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 f2a0fe86b..49b8ee5b0 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -64,11 +64,13 @@ class OverseasTokenServiceTest { assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(parameters.getClaims().getClaims()) - .containsEntry("userId", 42L).containsEntry("tenantId", 100L) - .containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L) + .containsEntry("user_id", 42L).containsEntry("tenant_id", 100L) + .containsEntry("membership_id", 84L) + .containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB") + .containsEntry("permission_version", 7L) .containsEntry("roles", Set.of("MERCHANT_ADMIN")) .containsEntry("authorities", Set.of("merchant:order:list")) - .doesNotContainKeys("user_id", "tenant_id", "app_code"); + .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 378011e92..5f3df70ab 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -751,7 +751,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', - warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', @@ -9636,21 +9636,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -9665,7 +9672,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), @@ -10375,22 +10381,29 @@ FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史 DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule-query:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102187,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id=5102185 OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -10405,7 +10418,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) @@ -10433,7 +10445,7 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -10442,7 +10454,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ @@ -14221,7 +14233,7 @@ CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'updated_by', '最 CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_code', '平台内唯一仓库编码'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_name', '仓库名称'); -CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC、FBA、OVERSEAS'); +CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse', 'warehouse_type', '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line1', '主地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_line2', '补充地址行'); CALL queue_column_comment('baiyue_warehouse', 'wh_warehouse_address', 'address_type', '地址类型,例如OPERATING或RETURN'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 6ef1ffe70..a98ec7d28 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -41,7 +41,7 @@ public class PlatformCarrierScheduleQueryController { @Operation(summary = "查询六家船公司合规适配策略") @GetMapping("/carriers") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> carriers(@AuthenticationPrincipal AuthenticatedUser user) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.carriers()); @@ -57,7 +57,7 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); @@ -65,7 +65,7 @@ public class PlatformCarrierScheduleQueryController { } @GetMapping("/{queryId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java index 2b586c181..6b1c5c271 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleController.java @@ -47,7 +47,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); @@ -55,7 +55,7 @@ public class PlatformPortScheduleController { } @GetMapping("/{portScheduleId}/history") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:view')") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long portScheduleId) { FirstlegScopeGuard.platform(user); diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 93a182dc1..80636650e 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -150,22 +150,29 @@ FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史 DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule-query:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; + DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102184 AND 5102188 + WHERE (id IN (5102184,5102186,5102187,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' - WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) + OR id=5102185 OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -180,7 +187,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), - (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) @@ -208,7 +214,7 @@ BEGIN END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -217,7 +223,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql index fb055126f..0c90d88d5 100644 --- a/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql +++ b/firstleg/src/main/resources/db/phase3-port-schedule-schema.sql @@ -122,21 +122,28 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='港务船期历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.tenant_id=0 + AND permission.permission_code='platform:firstleg:port-schedule:view'; +DELETE FROM iam_permission +WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule:view'; + DROP PROCEDURE IF EXISTS assert_port_schedule_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_port_schedule_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102180 AND 5102183 + WHERE (id IN (5102180,5102182,5102183) AND permission_code <> CASE id WHEN 5102180 THEN 'platform:firstleg:port-schedule:list' - WHEN 5102181 THEN 'platform:firstleg:port-schedule:view' WHEN 5102182 THEN 'platform:firstleg:port-schedule:create' WHEN 5102183 THEN 'platform:firstleg:port-schedule:edit' END) + OR id=5102181 OR (permission_code LIKE 'platform:firstleg:port-schedule:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102180 AND 5102183)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT IN (5102180,5102182,5102183))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期港务船期权限迁移失败:5102180-5102183 ID 或权限码被其他语义占用'; @@ -151,7 +158,6 @@ INSERT INTO iam_permission route_path,http_method,sort_no,status,built_in,deleted) VALUES (5102180,0,'PLATFORM','MENU',5102000,'platform:firstleg:port-schedule:list','港务船期','/first-leg/port-schedules','GET',120,'ACTIVE',1,0), - (5102181,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:view','查看港务船期',NULL,'GET',10,'ACTIVE',1,0), (5102182,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:create','新增港务船期',NULL,'POST',20,'ACTIVE',1,0), (5102183,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule:edit','更新港务船期',NULL,'PUT',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index ff50fc8de..e63d175fa 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -7,11 +7,13 @@ paths: /api/platform/v1/carrier-schedule-queries/carriers: get: summary: List official carrier sources and compliant access modes + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks + x-permission: platform:firstleg:port-schedule-query:list parameters: - {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}} - {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} @@ -26,6 +28,7 @@ paths: '200': {description: Query tasks with source, freshness and rate-limit fields} post: summary: Create a query task linked to a real booking and allocated container + x-permission: platform:firstleg:port-schedule-query:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -38,18 +41,21 @@ paths: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get task, immutable attempts and history + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: Stable task detail} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get immutable task history + x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} /api/platform/v1/carrier-schedule-queries/{queryId}/attempts: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages + x-permission: platform:firstleg:port-schedule-query:attempt parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -62,6 +68,7 @@ paths: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Link a validated manual port-schedule observation without pretending automated success + x-permission: platform:firstleg:port-schedule-query:manual parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true diff --git a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml index 4016226a5..d96d74796 100644 --- a/firstleg/src/main/resources/openapi/port-schedules-v1.yaml +++ b/firstleg/src/main/resources/openapi/port-schedules-v1.yaml @@ -7,6 +7,7 @@ paths: /api/platform/v1/port-schedules: get: summary: Query schedules by customer, ocean booking, allocated container or expiry + x-permission: platform:firstleg:port-schedule:list parameters: - {name: customerId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: oceanBookingId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -20,6 +21,7 @@ paths: description: HTTP 200 business envelope containing a page of schedule views post: summary: Create a source observation linked to a real ocean booking + x-permission: platform:firstleg:port-schedule:create parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -32,10 +34,12 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Get schedule and immutable history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Schedule detail with computed expired flag} put: summary: Update source observation with optimistic locking + x-permission: platform:firstleg:port-schedule:edit parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -48,6 +52,7 @@ paths: parameters: [{$ref: '#/components/parameters/PortScheduleId'}] get: summary: Query immutable source observation history + x-permission: platform:firstleg:port-schedule:list responses: '200': {description: Ordered CREATE and UPDATE history records} components: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 173c7cbb1..1f1d1c0a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -17,10 +17,10 @@ class PlatformCarrierScheduleQueryControllerContractTest { assertThat(PlatformCarrierScheduleQueryController.class.getAnnotation(RequestMapping.class).value()) .containsExactly("/api/platform/v1/carrier-schedule-queries"); Map permissions = Map.of( - "carriers", "platform:firstleg:port-schedule-query:view", + "carriers", "platform:firstleg:port-schedule-query:list", "page", "platform:firstleg:port-schedule-query:list", - "detail", "platform:firstleg:port-schedule-query:view", - "history", "platform:firstleg:port-schedule-query:view", + "detail", "platform:firstleg:port-schedule-query:list", + "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", "attempt", "platform:firstleg:port-schedule-query:attempt", "manualResult", "platform:firstleg:port-schedule-query:manual"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java index 42959cc52..fb2edf48d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformPortScheduleControllerContractTest.java @@ -18,8 +18,8 @@ class PlatformPortScheduleControllerContractTest { .containsExactly("/api/platform/v1/port-schedules"); Map permissions = Map.of( "page", "platform:firstleg:port-schedule:list", - "detail", "platform:firstleg:port-schedule:view", - "history", "platform:firstleg:port-schedule:view", + "detail", "platform:firstleg:port-schedule:list", + "history", "platform:firstleg:port-schedule:list", "create", "platform:firstleg:port-schedule:create", "update", "platform:firstleg:port-schedule:edit"); permissions.forEach((name, permission) -> { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java index e1049bca6..e5b46e04c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/PortScheduleSqlContractTest.java @@ -37,9 +37,11 @@ class PortScheduleSqlContractTest { @Test void reservesDedicatedPlatformPermissionsForBothOperationalRoles() throws Exception { assertThat(sql()) - .contains("5102180 AND 5102183") + .contains("id IN (5102180,5102182,5102183)") .contains("platform:firstleg:port-schedule:list") - .contains("platform:firstleg:port-schedule:view") + .contains("permission.permission_code='platform:firstleg:port-schedule:view'") + .contains("permission_code='platform:firstleg:port-schedule:view'") + .doesNotContain("'platform:firstleg:port-schedule:view','查看港务船期'") .contains("platform:firstleg:port-schedule:create") .contains("platform:firstleg:port-schedule:edit") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')"); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index dfcc35264..7c9b1e6d5 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -4,6 +4,7 @@ - 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。 - 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。 +- 修改海外 Token、Audience、JWKS、区域路由或服务身份时,先读 `../../docs/跨区域认证与服务通信契约.md`。 ## 快速索引 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index 22d1fb7e3..b085e38c4 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -4,6 +4,7 @@ - 负责客户主档、成员、企业资料/审核、平台用户、角色权限、数据范围和授权快照。 - 账号凭据/Token 属于 `auth/`,文件内容属于 `file/`;资质登记只接收 File 的内部元数据调用。 +- 修改用户可见层级、菜单权限、列表/详情只读语义或权限资源前,先读 `../../docs/平台客户层级与权限契约.md`。 - 当前仅“平台—客户”两级:`iam_tenant.id=0` 为平台作用域,`id>0` 为客户主档;不得新增真实租户层。 ## 模块入口 diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index 4c16d9d87..e0f9c4b62 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.annotation.Resource; import jakarta.annotation.PostConstruct; @@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.util.List; @RestController @RequestMapping("/internal/warehouses") @@ -39,6 +41,13 @@ public class InternalWarehouseController { return ApiResponse.success(service.activeDetail(PLATFORM_TENANT_ID, id)); } + @GetMapping("/overseas-partners") + public ApiResponse> overseasPartners( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeOverseasPartners()); + } + private void verifyToken(String token) { byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(expected, supplied)) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java new file mode 100644 index 000000000..0de6fc8d7 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/OverseasPartnerWarehouseResponse.java @@ -0,0 +1,17 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "OverseasPartnerWarehouseResponse", description = "Active overseas partner warehouse option / 启用的海外合作仓候选") +public record OverseasPartnerWarehouseResponse( + @Schema(description = "Platform public warehouse ID / 平台公共仓库 ID") Long id, + @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称") String warehouseName, + @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode +) { + public static OverseasPartnerWarehouseResponse of(WarehouseEntity warehouse) { + return new OverseasPartnerWarehouseResponse(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getCountryCode()); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index c48a3f81d..31d103bec 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -29,8 +29,8 @@ public final class WarehouseRequests { @NotBlank @Size(max = 50) String warehouseCode, @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 120) String warehouseName, - @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "FBA", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}, defaultValue = "DOMESTIC") - @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, + @Schema(description = "Warehouse type; defaults to DOMESTIC / 仓库类型,默认 DOMESTIC", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}, defaultValue = "DOMESTIC") + @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "ISO 3166-1 alpha-2 country code; DOMESTIC defaults to CN / ISO 两位国家代码,DOMESTIC 默认 CN", example = "US", pattern = "[A-Z]{2}") @Pattern(regexp = "[A-Z]{2}") String countryCode, @Schema(description = "UTC offset; DOMESTIC defaults to UTC+8 / UTC 偏移量,DOMESTIC 默认 UTC+8", example = "UTC-8") @@ -104,8 +104,8 @@ public final class WarehouseRequests { String countryCode, @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") String capabilityCode, - @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "DOMESTIC", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) - @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS") String warehouseType, + @Schema(description = "Warehouse type filter / 仓库类型筛选", example = "OVERSEAS_PARTNER", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) + @Pattern(regexp = "DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER") String warehouseType, @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) @Pattern(regexp = "ACTIVE|INACTIVE") String status ) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java index f990c2322..9c300aed9 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseSummaryResponse.java @@ -13,7 +13,7 @@ public record WarehouseSummaryResponse( @Schema(description = "Platform public scope ID, fixed to 0 / 平台公共作用域,固定为 0") Long tenantId, @Schema(description = "Warehouse code / 仓库编码") String warehouseCode, @Schema(description = "Warehouse name / 仓库名称") String warehouseName, - @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS"}) String warehouseType, + @Schema(description = "Warehouse type / 仓库类型", allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) String warehouseType, @Schema(description = "ISO two-letter country code / 国家二字码") String countryCode, @Schema(description = "UTC offset / UTC 偏移量") String timezone, @Schema(description = "Contact name / 联系人") String contactName, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java index e391ee0d4..03e447e25 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -27,7 +27,8 @@ public class WarehouseEntity extends BaseEntity { private String warehouseCode; @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓") private String warehouseName; - @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS") + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS_PARTNER", + allowableValues = {"DOMESTIC", "FBA", "OVERSEAS", "OVERSEAS_PARTNER"}) private String warehouseType; @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US") private String countryCode; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 9b7c1bff3..304268c00 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -59,6 +59,15 @@ public class WarehouseRepository { .eq(WarehouseEntity::getStatus, "ACTIVE")); } + public List findActiveByType(long tenantId, String warehouseType) { + return warehouses.selectList(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseType, warehouseType) + .eq(WarehouseEntity::getStatus, "ACTIVE") + .orderByAsc(WarehouseEntity::getWarehouseName) + .orderByAsc(WarehouseEntity::getId)); + } + public boolean existsCode(long tenantId, String code, Long excludedId) { return warehouses.selectCount(new LambdaQueryWrapper() .eq(WarehouseEntity::getTenantId, tenantId) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 76456870f..06badc425 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.dto.WarehouseSummaryResponse; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; @@ -86,6 +87,14 @@ public class WarehouseApplicationService { return repository.activePage(tenantId, query); } + public List activeOverseasPartners() { + return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, + WarehouseSaveNormalizer.TYPE_OVERSEAS_PARTNER) + .stream() + .map(OverseasPartnerWarehouseResponse::of) + .toList(); + } + public WarehouseDetailResponse activeDetail(long tenantId, long id) { WarehouseEntity warehouse = repository.findOwned(tenantId, id); if (warehouse == null) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java index 841ae7216..4608f050b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseImportService.java @@ -56,7 +56,7 @@ public class WarehouseImportService { Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明"); instruction.createCell(1).setCellValue( - "默认仅新增;仓库类型默认 DOMESTIC,状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); + "默认仅新增;仓库类型默认 DOMESTIC,可填 OVERSEAS_PARTNER 表示海外合作仓;状态默认 ACTIVE;DOMESTIC 默认 CN/UTC+8;FBA 名称可不填并默认使用编码,多时区州必须显式填写时区;更新不覆盖联系人和备注"); Row header = sheet.createRow(2); for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); @@ -202,8 +202,8 @@ public class WarehouseImportService { } else { optionalLength(row, errors, "仓库名称", row.name(), 120); } - if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS")) { - errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA 或 OVERSEAS")); + if (!row.type().isBlank() && !row.type().matches("(?i)DOMESTIC|FBA|OVERSEAS|OVERSEAS_PARTNER")) { + errors.add(new WarehouseImportError(row.row(), "仓库类型", "只能填写 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER")); } if (!row.countryCode().isBlank() && !row.countryCode().matches("(?i)[A-Z]{2}")) { errors.add(new WarehouseImportError(row.row(), "国家二字码", "必须是两个英文字母")); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java index fe901037a..69d09eacf 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseSaveNormalizer.java @@ -15,6 +15,8 @@ final class WarehouseSaveNormalizer { static final long PUBLIC_TENANT_ID = 0L; static final String TYPE_DOMESTIC = "DOMESTIC"; static final String TYPE_FBA = "FBA"; + static final String TYPE_OVERSEAS = "OVERSEAS"; + static final String TYPE_OVERSEAS_PARTNER = "OVERSEAS_PARTNER"; static final String STATUS_ACTIVE = "ACTIVE"; static final String DEFAULT_ADDRESS_TYPE = "WAREHOUSE"; static final String DEFAULT_DOMESTIC_COUNTRY = "CN"; @@ -68,8 +70,9 @@ final class WarehouseSaveNormalizer { static NormalizedSaveRequest normalize(WarehouseRequests.SaveRequest request, long tenantId) { String warehouseType = upperOrDefault(request.warehouseType(), TYPE_DOMESTIC); - if (!TYPE_DOMESTIC.equals(warehouseType) && !TYPE_FBA.equals(warehouseType) && !"OVERSEAS".equals(warehouseType)) { - throw new BizException(ResultCode.VALIDATION_FAILED, "Warehouse type must be DOMESTIC, FBA or OVERSEAS / 仓库类型只能为 DOMESTIC、FBA 或 OVERSEAS"); + if (!Set.of(TYPE_DOMESTIC, TYPE_FBA, TYPE_OVERSEAS, TYPE_OVERSEAS_PARTNER).contains(warehouseType)) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "Warehouse type must be DOMESTIC, FBA, OVERSEAS or OVERSEAS_PARTNER / 仓库类型只能为 DOMESTIC、FBA、OVERSEAS 或 OVERSEAS_PARTNER"); } String status = upperOrDefault(request.status(), STATUS_ACTIVE); if (!STATUS_ACTIVE.equals(status) && !"INACTIVE".equals(status)) { diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index 044e55cb3..d61950cf9 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS wh_warehouse ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共仓库固定使用 tenant_id=0', warehouse_code VARCHAR(50) NOT NULL COMMENT '平台内唯一仓库编码', warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', - warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC、FBA、OVERSEAS', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'DOMESTIC' COMMENT '公共仓库类型:DOMESTIC国内仓、FBA亚马逊仓、OVERSEAS普通海外仓、OVERSEAS_PARTNER海外合作仓', country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地UTC偏移量', contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java index aa3ff07a4..ae64a2e25 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalWarehouseControllerTest.java @@ -3,6 +3,7 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.service.WarehouseApplicationService; import org.junit.jupiter.api.BeforeEach; @@ -71,4 +72,15 @@ class InternalWarehouseControllerTest { verifyNoInteractions(service); } + + @Test + void listsActiveOverseasPartnerOptionsForInternalCallers() { + var option = new OverseasPartnerWarehouseResponse(200002L, "US-PARTNER-LAX", "洛杉矶合作仓", "US"); + when(service.activeOverseasPartners()).thenReturn(List.of(option)); + + var response = controller.overseasPartners(INTERNAL_TOKEN); + + assertThat(response.data()).containsExactly(option); + verify(service).activeOverseasPartners(); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index a3eddd161..bd35a1d56 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -116,4 +116,37 @@ class WarehouseApplicationServiceTest { .isEqualTo(ResultCode.VALIDATION_FAILED); verify(repository, never()).insertWarehouse(any()); } + + @Test + void createsOverseasPartnerWarehouseAsPlatformPublicMasterData() { + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(null, "US-PARTNER-LAX", "洛杉矶合作仓", + "OVERSEAS_PARTNER", "US", "UTC-8", "Alex", "+1-213-555-0100", null, null, null, + new WarehouseRequests.AddressRequest(null, "US", "CA", "Los Angeles", null, + "90001", "123 Partner Ave", null, null, null), + java.util.List.of(new WarehouseRequests.CapabilityRequest("STORAGE", "仓储", true, null, null))); + + service.create(request); + + var warehouse = org.mockito.ArgumentCaptor.forClass(WarehouseEntity.class); + verify(repository).insertWarehouse(warehouse.capture()); + assertThat(warehouse.getValue().getTenantId()).isZero(); + assertThat(warehouse.getValue().getWarehouseType()).isEqualTo("OVERSEAS_PARTNER"); + assertThat(warehouse.getValue().getCountryCode()).isEqualTo("US"); + assertThat(warehouse.getValue().getTimezone()).isEqualTo("UTC-8"); + } + + @Test + void exposesOnlyActiveOverseasPartnerWarehousesToInternalConsumers() { + WarehouseEntity partner = new WarehouseEntity(); + partner.setId(2L); + partner.setWarehouseCode("US-PARTNER-LAX"); + partner.setWarehouseName("洛杉矶合作仓"); + partner.setCountryCode("US"); + when(repository.findActiveByType(0L, "OVERSEAS_PARTNER")).thenReturn(java.util.List.of(partner)); + + assertThat(service.activeOverseasPartners()) + .extracting("id", "warehouseCode", "warehouseName", "countryCode") + .containsExactly(tuple(2L, "US-PARTNER-LAX", "洛杉矶合作仓", "US")); + verify(repository).findActiveByType(0L, "OVERSEAS_PARTNER"); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java index aa7ef5033..1e0837d5a 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseImportServiceTest.java @@ -64,6 +64,22 @@ class WarehouseImportServiceTest { assertThat(captor.getAllValues().get(1).warehouseName()).isEqualTo("FBA-LAX9"); } + @Test + @DisplayName("海外合作仓类型可通过批量导入进入公共仓库主数据") + void shouldImportOverseasPartnerWarehouse() throws Exception { + byte[] file = withRows(service.template(), new String[][] { + {"US-PARTNER-LAX", "洛杉矶合作仓", "OVERSEAS_PARTNER", "US", "CA", "Los Angeles", "", "123 Partner Ave", "90001", "UTC-8", "", "", "", "", ""} + }); + + WarehouseImportResult result = service.importFile(0L, "warehouses.xlsx", file, null); + + assertThat(result.success()).isTrue(); + var captor = org.mockito.ArgumentCaptor.forClass(WarehouseRequests.SaveRequest.class); + verify(warehouseService).create(captor.capture()); + assertThat(captor.getValue().tenantId()).isZero(); + assertThat(captor.getValue().warehouseType()).isEqualTo("OVERSEAS_PARTNER"); + } + @Test @DisplayName("任一行错误时整批零写入") void shouldRejectWholeBatchWhenAnyRowInvalid() throws Exception { From d3183d8f2ce62dd6ed7cf36da0272a94e268400a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 13:34:29 +0800 Subject: [PATCH 013/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 62 +++++++++++++------ .../db/phase3-ocean-booking-schema.sql | 30 ++++++--- .../db/phase3-truck-appointment-schema.sql | 19 +++--- .../initialization-baseline-contract.test.mjs | 22 +++++++ scripts/local-db-full-acceptance.ps1 | 4 +- 5 files changed, 97 insertions(+), 40 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index dfb4d32e9..1b06852e8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9196,16 +9196,22 @@ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102140 AND 5102149 - AND permission_code <> CASE id - WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' - WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' - END) + WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') + OR (id = 5102141 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:list', + 'platform:firstleg:ocean-booking:view')) + OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') + OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') + OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') + OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') + OR (id = 5102147 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:cancel', + 'platform:firstleg:ocean-booking:edit')) + OR (id = 5102148 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:change', + 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) ) THEN @@ -9217,6 +9223,10 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; +UPDATE iam_permission + SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 + WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9236,6 +9246,9 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.id=5102141; +UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); @@ -9321,14 +9334,13 @@ CREATE PROCEDURE assert_truck_appointment_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102150 AND 5102154 - AND permission_code <> CASE id - WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create' - WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit' - WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate' - END) + WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') + OR (id = 5102151 AND permission_code NOT IN ( + 'platform:firstleg:truck-appointment:list', + 'platform:firstleg:truck-appointment:view')) + OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') + OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') + OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') OR (permission_code LIKE 'platform:firstleg:truck-appointment:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102150 AND 5102154)) ) THEN @@ -9340,6 +9352,10 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; +UPDATE iam_permission + SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 + WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9358,6 +9374,10 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); +DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.id=5102151; +UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; + USE baiyue_firstleg; DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema; @@ -9374,10 +9394,12 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149 + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND id BETWEEN 5102140 AND 5102149 AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154 + WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 + AND id BETWEEN 5102150 AND 5102154 AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入'; END IF; diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index a49ede7f6..9e50c4363 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -238,16 +238,22 @@ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102140 AND 5102149 - AND permission_code <> CASE id - WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102141 THEN 'platform:firstleg:ocean-booking:list' - WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create' - WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release' - WHEN 5102147 THEN 'platform:firstleg:ocean-booking:edit' - WHEN 5102148 THEN 'platform:firstleg:ocean-booking:delete' - WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate' - END) + WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') + OR (id = 5102141 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:list', + 'platform:firstleg:ocean-booking:view')) + OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') + OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') + OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') + OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') + OR (id = 5102147 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:cancel', + 'platform:firstleg:ocean-booking:edit')) + OR (id = 5102148 AND permission_code NOT IN ( + 'platform:firstleg:ocean-booking:change', + 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) ) THEN @@ -259,6 +265,10 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; +UPDATE iam_permission + SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 + WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index 311cfa4ed..efc2bab89 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -109,14 +109,13 @@ CREATE PROCEDURE assert_truck_appointment_permission_ids_available() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id BETWEEN 5102150 AND 5102154 - AND permission_code <> CASE id - WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102151 THEN 'platform:firstleg:truck-appointment:list' - WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create' - WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit' - WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate' - END) + WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') + OR (id = 5102151 AND permission_code NOT IN ( + 'platform:firstleg:truck-appointment:list', + 'platform:firstleg:truck-appointment:view')) + OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') + OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') + OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') OR (permission_code LIKE 'platform:firstleg:truck-appointment:%' AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102150 AND 5102154)) ) THEN @@ -128,6 +127,10 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; +UPDATE iam_permission + SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 + WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 9d12972a5..87fd77314 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -4,6 +4,8 @@ import test from 'node:test' const sql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-admin-rbac.sql', import.meta.url), 'utf8') +const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') +const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) @@ -31,3 +33,23 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => assert.ok(palletSeed, 'missing pallet initialization seed') assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) + +test('三期权限迁移允许脚本历史版本写入的旧权限并在随后收敛', () => { + for (const source of [sql, oceanBookingSchema]) { + assert.match(source, /id = 5102141[\s\S]*?ocean-booking:view/) + assert.match(source, /id = 5102147[\s\S]*?ocean-booking:cancel[\s\S]*?ocean-booking:edit/) + assert.match(source, /id = 5102148[\s\S]*?ocean-booking:change[\s\S]*?ocean-booking:delete/) + assert.match(source, /SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1/) + assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141/) + assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102143,5102144,5102145\)/) + } + + for (const source of [sql, truckAppointmentSchema]) { + assert.match(source, /id = 5102151[\s\S]*?truck-appointment:view/) + assert.match(source, /SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1/) + assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151/) + } + + assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/) + assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102150 AND 5102154/) +}) diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 1eff76bdd..cc401e801 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '00E8F9C24CE16FE6B4D2B6AF60460AE1AF1D264DFE110D4BDCE611F09EB69EAA' -$ExpectedLines = 15403 +$ExpectedSha256 = '8A269D219F8824352818BB427033F46F79CFB4652F36F0A9DA01A5913ABB50C6' +$ExpectedLines = 15673 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') From b139255d3b5e95a8108e231b95f43c52393718d5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 13:34:34 +0800 Subject: [PATCH 014/231] =?UTF-8?q?=E5=85=A5=E5=BA=93=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/头程项目目录结构与服务说明.md | 2 + .../mapper/LoadingCartonManifestMapper.java | 21 +++ .../mapper/LoadingCompletedEventMapper.java | 3 +- .../LoadingCompletedOutboxService.java | 31 ++++- .../LoadingCompletedOutboxServiceTest.java | 8 +- .../controller/InboundPlanController.java | 11 +- .../inboundplan/dto/InboundPlanResponse.java | 1 + .../dto/SupplementPalletRequest.java | 7 + .../inboundplan/entity/InboundPlan.java | 1 + .../inboundplan/entity/InboundPlanCarton.java | 35 +++++ .../mapper/InboundPlanCartonMapper.java | 34 +++++ .../inboundplan/mapper/InboundPlanMapper.java | 4 +- .../mapper/InboundPlanOrderMapper.java | 6 +- .../pda/controller/InboundPdaController.java | 18 +++ .../inboundplan/pda/dto/PdaRequests.java | 6 + .../inboundplan/pda/dto/PdaResponses.java | 3 + .../pda/repository/PdaWorkflowRepository.java | 25 ++++ .../service/InboundPdaWorkflowService.java | 125 +++++++++++++++++- .../service/InboundPlanService.java | 50 +++++-- .../service/LoadingCompletedConsumer.java | 30 ++++- .../src/main/resources/db/lastleg.sql | 27 +++- .../InboundPdaWorkflowServiceTest.java | 73 +++++++++- .../service/InboundPlanServiceTest.java | 50 +++---- .../service/LoadingCompletedConsumerTest.java | 19 ++- 24 files changed, 528 insertions(+), 62 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 91a3f12ad..f1a996425 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -155,6 +155,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三 Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 + ## 6. 配置、部署和辅助目录 ### `config/` diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java new file mode 100644 index 000000000..4ee4e2079 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCartonManifestMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 读取国内已打印的稳定箱唛池;普通装柜不在国内逐箱确认归属。 */ +@Mapper +public interface LoadingCartonManifestMapper { + @Select(""" + SELECT box_code FROM fl_inbound_carton_label + WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId} + AND batch_status='ACTIVE' + ORDER BY carton_index,id + """) + List labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("inboundDetailId") long inboundDetailId); + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index e6a340418..b613660dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -21,7 +21,7 @@ public interface LoadingCompletedEventMapper { COALESCE(root_order.order_id, o.order_id) AS root_order_id, o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, - i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark @@ -65,6 +65,7 @@ public interface LoadingCompletedEventMapper { private Integer aggregateVersion; private String containerNo; private String cabinetNo; private Long warehouseId; private String warehouseName; private Long accountId; private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; + private Long loadingPlanItemId; private String sourceOrderNo; private Long rootOrderId; private String inboundCode; private Long destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 3d365eebe..922cd7cca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -15,6 +15,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,26 +38,29 @@ import java.util.Objects; public class LoadingCompletedOutboxService { private static final long HANDOVER_VERSION = 2L; @Resource private LoadingCompletedEventMapper snapshotMapper; + @Resource private LoadingCartonManifestMapper cartonManifestMapper; @Resource private InboundOutboxMapper outboxMapper; @Resource private ObjectMapper objectMapper; public void publish(long planId) { List rows = snapshotMapper.rows(planId); if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + Map> cartonMarks = cartonLabelPools(rows); List palletRows = snapshotMapper.palletRows(planId); rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, java.util.TreeMap::new, java.util.stream.Collectors.toList())) .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows, - palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList()))); + palletRows.stream().filter(item -> accountId.equals(item.getAccountId())).toList(), cartonMarks))); } - private EventEnvelope envelope(long accountId, List rows, List palletRows) { + private EventEnvelope envelope(long accountId, List rows, List palletRows, + Map> cartonMarks) { Row first = rows.get(0); Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); List orders = byInbound.values().stream() .map(value -> order(value, palletRows.stream() - .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList())).toList(); + .filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList(); var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()), first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), string(first.getWarehouseId()), first.getWarehouseName(), orders); @@ -70,7 +74,8 @@ public class LoadingCompletedOutboxService { traceId, eventKey, null, payloadNode, hash).requireValid(); } - private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows) { + private FirstLegLoadingPlanCompletedV2.Order order(List values, List palletRows, + Map> cartonMarks) { List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); Row first = sorted.get(0); List items = sorted.stream().map(row -> @@ -79,7 +84,8 @@ public class LoadingCompletedOutboxService { row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(), - row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark())) + row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, + cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; List pallets = dropship(first.getBusinessType()) @@ -96,6 +102,21 @@ public class LoadingCompletedOutboxService { sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets); } + /** + * 国内只确认本柜实装箱数,不扫每张箱唛。事件携带全量可用标签池,海外首扫才确权具体箱唛。 + */ + private Map> cartonLabelPools(List rows) { + Map> result = new java.util.LinkedHashMap<>(); + for (Row row : rows) { + if (dropship(row.getBusinessType())) continue; + List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), + row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); + if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + result.put(row.getLoadingPlanItemId(), labels); + } + return result; + } + private List palletSnapshots(List values, List source) { if (source.isEmpty()) return List.of(); Map> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 959518478..d7c085e81 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundOutboxMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow; +import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -19,19 +20,23 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class LoadingCompletedOutboxServiceTest { private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + private final LoadingCartonManifestMapper manifests = mock(LoadingCartonManifestMapper.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); @BeforeEach void setup() { ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests); ReflectionTestUtils.setField(service,"outboxMapper",outbox); ReflectionTestUtils.setField(service,"objectMapper",mapper); + when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1")); } @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { @@ -87,12 +92,13 @@ class LoadingCompletedOutboxServiceTest { r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); - r.setInboundCode("RK"+inbound); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); + r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } + private PalletRow pallet(long account,long inbound,int allocation,String palletNo,int allocated, long detail,String sku,int packages,int quantity,String boxCode) { PalletRow row = new PalletRow(); row.setAccountId(account); row.setSourceInboundId(inbound); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index a360ddde1..3e9cb5d66 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import io.swagger.v3.oas.annotations.Operation; @@ -25,7 +26,7 @@ public class InboundPlanController { @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") - @Operation(summary = "Convert the entire container to one inbound plan") + @Operation(summary = "Convert one inbound order to one inbound plan") public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); @@ -39,6 +40,14 @@ public class InboundPlanController { return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); } + @PostMapping("/{planNo}/pallets/supplement") + @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse") + public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody SupplementPalletRequest request) { + return ApiResponse.success(service.supplementPallet(scope(user, request.customerId()), planNo, request)); + } + @PostMapping("/fba-appointments/batch") @Operation(summary = "Create one FBA appointment for selected inbound lines") public ApiResponse batchFbaAppointment( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index f50f69579..e4e1c5e70 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -8,6 +8,7 @@ public record InboundPlanResponse( Long id, String planNo, String customerId, + Long sourceOrderId, String containerNo, String status, int lineCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java new file mode 100644 index 000000000..47aa5b910 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 托盘纸不足时,按计划内目的仓补打一张空托盘标签。 */ +public record SupplementPalletRequest(@NotBlank String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String customerId) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index a796f8679..ec6ccd5ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -15,6 +15,7 @@ public class InboundPlan { private Long id; private String planNo; private String customerId; + private Long sourceOrderId; private String containerNo; private String status; private Integer lineCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java new file mode 100644 index 000000000..7229b4498 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.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_inbound_plan_carton") +public class InboundPlanCarton { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private Long orderId; + private String sourceInboundDetailId; + private String skuCode; + private String cartonMark; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private Long planId; + private Long lineId; + private Long planPalletId; + private Long warehousePalletId; + private Long warehouseLocationId; + private Long pdaTaskId; + private String status; + private Integer version; + private LocalDateTime boundAt; + private LocalDateTime receivedAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java new file mode 100644 index 000000000..f95d5cbc7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface InboundPlanCartonMapper extends BaseMapper { + @Select(""" + SELECT l.* FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + WHERE l.plan_id=#{planId} + AND d.source_inbound_detail_id=#{sourceInboundDetailId} + LIMIT 1 + """) + com.baiyue.lastleg.inboundplan.entity.InboundPlanLine findPlanLine(@Param("planId") Long planId, + @Param("sourceInboundDetailId") String sourceInboundDetailId); + + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_carton + WHERE plan_id=#{planId} AND line_id=#{lineId} AND status='ON_PALLET' + """) + long scannedCount(@Param("planId") Long planId, @Param("lineId") Long lineId); + + @Select(""" + SELECT EXISTS(SELECT 1 FROM ll_inbound_plan_line l + JOIN ll_order_detail d ON d.order_id=l.order_id + JOIN ll_inbound_plan_carton c ON c.source_inbound_detail_id=d.source_inbound_detail_id + WHERE l.plan_id=#{planId}) + """) + boolean hasLabelPool(@Param("planId") Long planId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 893df0507..c8c7f0c6a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -8,6 +8,6 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface InboundPlanMapper extends BaseMapper { - @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND container_no=#{containerNo} LIMIT 1") - InboundPlan findByContainer(@Param("customerId") String customerId, @Param("containerNo") String containerNo); + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") + InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index ed66ee48d..e1e02b377 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -29,12 +29,12 @@ public interface InboundPlanOrderMapper { "<> COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) THEN 1 ELSE 0 END) " + "AS destinationMismatchCount " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + - "WHERE o.customer_id=#{customerId} AND o.container_no=#{containerNo} " + + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.fba_warehouse_code," + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + "ORDER BY o.id FOR UPDATE") - List lockContainerOrders(@Param("customerId") String customerId, - @Param("containerNo") String containerNo); + List lockOrder(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 7ade7f61a..b4a508bca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -62,6 +62,24 @@ public class InboundPdaController { return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); } + @PostMapping("/cartons/scan") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.ScanCarton request) { + requireOperate(user); + return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request)); + } + + @PostMapping("/cartons/remove") + public ApiResponse removeCarton(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.RemoveCarton request) { + requireOperate(user); + return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request)); + } + @GetMapping("/allocations/{allocationId}/merge-candidates") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java index 3a1d3880b..685ccf911 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -16,4 +16,10 @@ public final class PdaRequests { public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record ScanCarton(@NotBlank String cartonMark, @Positive long planPalletId, + @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 0a9b26291..7a95472ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -23,4 +23,7 @@ public final class PdaResponses { public record Merge(String result, String mergeGroupNo, long sourceOrderId, long targetOrderId, int movedPackageCount, BigDecimal movedVolumeCbm, boolean sourcePalletScrapped) {} + + public record Carton(String result, String cartonMark, long planPalletId, long allocationId, + int palletActualPackageCount) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index e39b826e6..6d8dd4cbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; @@ -29,6 +31,7 @@ public class PdaWorkflowRepository { @Resource private InboundPdaTaskMapper tasks; @Resource private InboundPdaOperationMapper operations; @Resource private InboundPalletMergeMapper merges; + @Resource private InboundPlanCartonMapper cartons; public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() @@ -45,6 +48,27 @@ public class PdaWorkflowRepository { .eq(InboundPlanPalletAllocation::getId, allocationId).last("FOR UPDATE")); } + public InboundPlanPalletAllocation lockAllocation(Long planId, Long lineId, Long palletId) { + return allocations.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getPlanId, planId).eq(InboundPlanPalletAllocation::getLineId, lineId) + .eq(InboundPlanPalletAllocation::getPalletId, palletId).last("FOR UPDATE")); + } + + public InboundPlanCarton lockCarton(String customerId, String cartonMark) { + return cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark) + .last("FOR UPDATE")); + } + + public List cartons(Long planId) { + return cartons.selectList(new LambdaQueryWrapper().eq(InboundPlanCarton::getPlanId, planId)); + } + public InboundPlanLine lineForCarton(Long planId, InboundPlanCarton carton) { + return cartons.findPlanLine(planId, carton.getSourceInboundDetailId()); + } + public long scannedCartons(Long planId, Long lineId) { return cartons.scannedCount(planId, lineId); } + public boolean hasCartonLabelPool(Long planId) { return cartons.hasLabelPool(planId); } + public InboundPlanPallet lockPallet(Long planId, Long palletId) { return pallets.selectOne(new LambdaQueryWrapper() .eq(InboundPlanPallet::getPlanId, planId).eq(InboundPlanPallet::getId, palletId).last("FOR UPDATE")); @@ -76,4 +100,5 @@ public class PdaWorkflowRepository { public void update(InboundPdaTask value) { tasks.updateById(value); } public void update(InboundPlanPallet value) { pallets.updateById(value); } public void update(InboundPlanPalletAllocation value) { allocations.updateById(value); } + public void update(InboundPlanCarton value) { cartons.updateById(value); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 680e43d57..b09a218c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; @@ -99,9 +100,13 @@ public class InboundPdaWorkflowService { && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; throw conflict("Planned pallet is already assigned to another warehouse resource"); } - String businessId = planNo + ":" + planPalletId; + String destinationKey = destinationKey(pallet); + String businessId = plan.getContainerNo() + ":" + destinationKey; + // A container is unloaded by destination pallet, not by inbound-plan pallet. + // The stable reservation key lets each plan from the same container reuse it. + String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId; long lockId = warehouse.reserve(customerId, request.warehouseId(), request.palletId(), request.locationId(), - "INBOUND_PLAN_PALLET", businessId, request.idempotencyKey()); + "INBOUND_CONTAINER_DESTINATION_PALLET", businessId, reservationKey); pallet.setWarehousePalletId(request.palletId()); pallet.setWarehouseLocationId(request.locationId()); pallet.setWarehouseResourceLockId(lockId); @@ -128,6 +133,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation count(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -155,6 +161,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, PdaRequests.PackageOperation request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Operation.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -193,6 +200,90 @@ public class InboundPdaWorkflowService { return result; } + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ + @Transactional + public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || (carton.getPlanId() != null && !Objects.equals(carton.getPlanId(), plan.getId()))) { + throw notFound("Carton mark is not available to this inbound plan"); + } + if (!("UNASSIGNED".equals(carton.getStatus()) || "EXPECTED".equals(carton.getStatus()))) { + throw conflict("Carton mark has already been scanned; remove it before scanning again"); + } + InboundPlanPallet pallet = requirePallet(plan.getId(), request.planPalletId()); + if (isNotAssigned(pallet)) throw conflict("Planned pallet must be assigned and locked before carton scanning"); + if (!sameWarehouse(carton, pallet)) throw conflict("Carton and pallet destination warehouses must match"); + InboundPlanLine line = carton.getLineId() == null + ? repository.lineForCarton(plan.getId(), carton) + : requireLine(plan.getId(), carton.getLineId()); + if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan"); + if (repository.scannedCartons(plan.getId(), line.getId()) >= value(line.getRequiredPackageCount())) { + throw conflict("This inbound plan line has reached its required carton count"); + } + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null) { + allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); allocation.setAllocatedVolumeCbm(ZERO_VOLUME); + allocation.setPlannedPackageCount(0); allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(ZERO_VOLUME); allocation.setStatus("PICKING"); allocation.setVersion(0); repository.insert(allocation); + } + BigDecimal perPackage = perPackageVolume(line); + carton.setOrderId(line.getOrderId()); carton.setPlanId(plan.getId()); carton.setLineId(line.getId()); + carton.setPlanPalletId(pallet.getId()); carton.setWarehousePalletId(pallet.getWarehousePalletId()); + carton.setWarehouseLocationId(pallet.getWarehouseLocationId()); carton.setPdaTaskId(task.getId()); + carton.setStatus("ON_PALLET"); carton.setBoundAt(LocalDateTime.now()); carton.setUpdatedAt(LocalDateTime.now()); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + allocation.setCountedPackageCount(value(allocation.getCountedPackageCount()) + 1); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) + 1); + allocation.setActualVolumeCbm(volume(allocation.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + allocation.setStatus(value(allocation.getPickedPackageCount()) >= value(allocation.getPlannedPackageCount()) ? "PICKED" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) + 1); + pallet.setActualVolumeCbm(volume(pallet.getActualVolumeCbm()).add(perPackage).setScale(4, RoundingMode.HALF_UP)); + repository.update(pallet); startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Carton result = new PdaResponses.Carton("SCANNED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "SCAN_CARTON", request.idempotencyKey(), request, result); + return result; + } + + @Transactional + public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Carton.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanCarton carton = repository.lockCarton(customerId, request.cartonMark()); + if (carton == null || !Objects.equals(carton.getPlanId(), plan.getId())) throw notFound("Carton mark is not expected by this inbound plan"); + if (!"ON_PALLET".equals(carton.getStatus()) || carton.getPlanPalletId() == null) throw conflict("Only a carton currently on a pallet may be removed"); + InboundPlanPallet pallet = requirePallet(plan.getId(), carton.getPlanPalletId()); + InboundPlanLine line = requireLine(plan.getId(), carton.getLineId()); + InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); + if (allocation == null || value(allocation.getPickedPackageCount()) <= 0 || value(pallet.getActualPackageCount()) <= 0) { + throw conflict("Carton pallet allocation is inconsistent"); + } + BigDecimal perPackage = perPackageVolume(line); + allocation.setCountedPackageCount(Math.max(0, value(allocation.getCountedPackageCount()) - 1)); + allocation.setPickedPackageCount(value(allocation.getPickedPackageCount()) - 1); + allocation.setActualVolumeCbm(nonNegative(volume(allocation.getActualVolumeCbm()).subtract(perPackage))); + allocation.setStatus(value(allocation.getPickedPackageCount()) == 0 ? "PENDING_COUNT" : "PICKING"); + bump(allocation); repository.update(allocation); + pallet.setActualPackageCount(value(pallet.getActualPackageCount()) - 1); + pallet.setActualVolumeCbm(nonNegative(volume(pallet.getActualVolumeCbm()).subtract(perPackage))); repository.update(pallet); + carton.setOrderId(null); carton.setPlanId(null); carton.setLineId(null); carton.setPlanPalletId(null); + carton.setWarehousePalletId(null); carton.setWarehouseLocationId(null); carton.setPdaTaskId(null); + carton.setStatus("UNASSIGNED"); carton.setBoundAt(null); carton.setVersion(value(carton.getVersion()) + 1); + carton.setUpdatedAt(LocalDateTime.now()); repository.update(carton); + PdaResponses.Carton result = new PdaResponses.Carton("REMOVED", carton.getCartonMark(), pallet.getId(), + allocation.getId(), pallet.getActualPackageCount()); + saveOperation(customerId, task.getId(), allocation.getId(), "REMOVE_CARTON", request.idempotencyKey(), request, result); + return result; + } + @Transactional public List mergeCandidates(String customerId, String planNo, long allocationId) { @@ -205,6 +296,7 @@ public class InboundPdaWorkflowService { public PdaResponses.Merge merge(String customerId, String planNo, long targetAllocationId, PdaRequests.ConfirmMerge request) { InboundPlan plan = requirePlan(customerId, planNo); + requireLegacyCounting(plan); InboundPdaOperation replay = replay(customerId, "MERGE", request.idempotencyKey(), request); if (replay != null) return read(replay, PdaResponses.Merge.class); InboundPdaTask task = requireActiveTask(customerId, plan); @@ -292,8 +384,16 @@ public class InboundPdaWorkflowService { throw conflict("All line picked package totals must exactly equal required package totals"); } } + List cartons = repository.cartons(plan.getId()); + if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); + } LocalDateTime now = LocalDateTime.now(); stockReceipts.stockIn(customerId, plan, task, lines, allocations, repository.pallets(plan.getId()), now); + for (InboundPlanCarton carton : cartons) { + carton.setStatus("AVAILABLE"); carton.setReceivedAt(now); carton.setUpdatedAt(now); + carton.setVersion(value(carton.getVersion()) + 1); repository.update(carton); + } task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); @@ -387,6 +487,21 @@ public class InboundPdaWorkflowService { return leftKey != null && leftKey.equals(rightKey); } + private boolean sameWarehouse(InboundPlanCarton carton, InboundPlanPallet pallet) { + String cartonKey = carton.getDestinationWarehouseId() != null + ? carton.getDestinationWarehouseId() : carton.getDestinationWarehouseCode(); + String palletKey = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + return cartonKey != null && cartonKey.equals(palletKey); + } + + private String destinationKey(InboundPlanPallet pallet) { + String value = pallet.getDestinationWarehouseId() != null + ? pallet.getDestinationWarehouseId() : pallet.getDestinationWarehouseCode(); + if (value == null || value.isBlank()) throw conflict("Planned pallet destination warehouse is empty"); + return value; + } + private void validateDestinationWarehouse(InboundPlanPallet pallet, long warehouseId) { String destinationId = pallet.getDestinationWarehouseId(); if (destinationId == null || destinationId.isBlank()) return; @@ -413,6 +528,12 @@ public class InboundPdaWorkflowService { repository.update(plan); } + private void requireLegacyCounting(InboundPlan plan) { + if (repository.hasCartonLabelPool(plan.getId())) { + throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); + } + } + private boolean isNotAssigned(InboundPlanPallet pallet) { return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null || pallet.getWarehouseResourceLockId() == null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 58570f0d0..588cd634d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -37,17 +38,13 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { - String containerNo = trimToNull(orderMapper.findContainerNo(customerId, selectedOrderId)); - if (containerNo == null) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Order does not exist in this tenant or container number is empty"); + List orders = orderMapper.lockOrder(customerId, selectedOrderId); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); } - - List orders = orderMapper.lockContainerOrders(customerId, containerNo); - if (orders.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Container orders not found"); - } - InboundPlan existing = planMapper.findByContainer(customerId, containerNo); + String containerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Inbound order container number is empty"); + InboundPlan existing = planMapper.findBySourceOrder(customerId, selectedOrderId); if (existing != null) { return detail(customerId, existing); } @@ -58,6 +55,7 @@ public class InboundPlanService { plan.setId(IdWorker.getId()); plan.setPlanNo("LIP" + plan.getId()); plan.setCustomerId(customerId); + plan.setSourceOrderId(selectedOrderId); plan.setContainerNo(containerNo); plan.setStatus("PENDING_ASSIGNMENT"); plan.setLineCount(orders.size()); @@ -92,11 +90,12 @@ public class InboundPlanService { throw new IllegalStateException("Failed to update order " + order.orderId()); } orderMapper.insertHistory(order.orderId(), order.currentStatus(), - "Container converted to inbound plan " + plan.getPlanNo(), now); + "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } return detail(customerId, plan); } + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() @@ -107,6 +106,32 @@ public class InboundPlanService { return detail(customerId, plan); } + @Transactional + public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if ("COMPLETED".equals(plan.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed plan cannot add pallets"); + List lines = lineMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getPlanId, plan.getId())); + boolean destinationExists = lines.stream().anyMatch(line -> request.destinationWarehouseId().equals(line.getDestinationWarehouseId())); + if (!destinationExists) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Destination warehouse is not part of this inbound plan"); + int sequence = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getPlanId, plan.getId())).stream().map(InboundPlanPallet::getSequenceNo) + .filter(Objects::nonNull).max(Integer::compareTo).orElse(0) + 1; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setPlanId(plan.getId()); pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", sequence)); + pallet.setDestinationWarehouseId(request.destinationWarehouseId()); pallet.setDestinationWarehouseCode(request.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(request.destinationWarehouseName()); pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); + pallet.setAllocatedVolumeCbm(BigDecimal.ZERO); pallet.setSequenceNo(sequence); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); + return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), + pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), + pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(), + null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount()); + } + private Map createLines(InboundPlan plan, List orders) { Map result = new HashMap<>(); for (SourceOrderSnapshot order : orders) { @@ -211,7 +236,8 @@ public class InboundPlanService { new LambdaQueryWrapper() .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) .orderByAsc(InboundPlanPalletAllocation::getId)); - return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getContainerNo(), + return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), + plan.getContainerNo(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), lines.stream().map(line -> new InboundPlanResponse.Line(line.getId(), line.getOrderId(), line.getInboundCode(), line.getFbaWarehouseCode(), line.getDestinationWarehouseId(), line.getDestinationWarehouseCode(), line.getDestinationWarehouseName(), 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 49fa9caef..3d076a2b3 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 @@ -5,6 +5,9 @@ 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.entity.InboundPlanCarton; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; +import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.DeserializationFeature; import jakarta.annotation.Resource; @@ -25,6 +28,8 @@ public class LoadingCompletedConsumer { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; + @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private InboundPlanService inboundPlans; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private DropshipOrderService dropshipOrders; @@ -32,7 +37,8 @@ public class LoadingCompletedConsumer { @Transactional public int consume(EventEnvelope envelope) { envelope.requireValid(); - if(!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType())||envelope.eventVersion()!=1) + if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) + || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2)) throw new IllegalArgumentException("不支持的尾程交接事件"); if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) throw new IllegalArgumentException("尾程交接事件区域无效"); @@ -71,6 +77,8 @@ public class LoadingCompletedConsumer { try { ObjectMapper compatibleMapper = objectMapper.copy() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + // V2 only adds immutable item remarks; the current tail-leg snapshot model + // intentionally ignores those fields until its schema exposes them. return compatibleMapper.treeToValue(envelope.payload(), FirstLegLoadingPlanCompletedV1.class); } catch(Exception exception) { throw new IllegalArgumentException("尾程事件负载无效",exception); } @@ -133,10 +141,12 @@ public class LoadingCompletedConsumer { detail.setDestinationWarehouseId(item.destinationWarehouseId()); detail.setDestinationWarehouseCode(item.destinationWarehouseCode()); detail.setDestinationWarehouseName(item.destinationWarehouseName()); detail.setFbaTrackingNo(item.fbaTrackingNo()); detail.setShipmentNo(item.shipmentNo()); detail.setShippingMark(item.shippingMark()); detailMapper.insert(detail); + createCartonLabelPool(customerId, item); } LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); + if ("FIRST_LEG".equals(orderType)) inboundPlans.convert(customerId, order.getId()); if ("ONE_CLICK_DROPSHIP".equals(orderType)) { long warehouseId; try { warehouseId = Long.parseLong(payload.warehouseId()); } @@ -146,6 +156,24 @@ public class LoadingCompletedConsumer { } } + private void createCartonLabelPool(String customerId, FirstLegLoadingPlanCompletedV1.Item item) { + List marks = item.cartonMarks() == null ? List.of() : item.cartonMarks(); + if (marks.isEmpty()) return; // 历史 V1 事件保持原有按箱数入库路径。 + if (marks.stream().anyMatch(mark -> mark == null || mark.isBlank()) || marks.stream().distinct().count() != marks.size()) { + throw new IllegalArgumentException("装柜事件的箱唛池不可包含空值或重复箱唛"); + } + for (String mark : marks) { + InboundPlanCarton carton = new InboundPlanCarton(); + carton.setId(IdWorker.getId()); carton.setCustomerId(customerId); + carton.setSourceInboundDetailId(item.sourceInboundDetailId()); carton.setSkuCode(item.skuCode()); + carton.setCartonMark(mark); carton.setDestinationWarehouseId(item.destinationWarehouseId()); + carton.setDestinationWarehouseCode(item.destinationWarehouseCode()); + carton.setDestinationWarehouseName(item.destinationWarehouseName()); carton.setStatus("UNASSIGNED"); + carton.setVersion(0); carton.setUpdatedAt(LocalDateTime.now()); + try { cartonMapper.insert(carton); } catch (DuplicateKeyException ignored) { } + } + } + private List pallets(FirstLegLoadingPlanCompletedV1.Order source, JsonNode rawSource) { if (source.pallets() != null && !source.pallets().isEmpty()) { return source.pallets().stream().map(pallet -> new DropshipRequests.Pallet(pallet.palletNo(), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6de76faf9..67935ab04 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -335,6 +335,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', @@ -344,8 +345,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_plan_no (plan_no), - UNIQUE KEY uk_ll_inbound_plan_container (customer_id, container_no) -) ENGINE=InnoDB COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; + UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id, source_order_id), + KEY idx_ll_inbound_plan_container (customer_id, container_no) +) ENGINE=InnoDB COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', @@ -362,6 +364,22 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( KEY idx_ll_inbound_line_plan (plan_id) ) ENGINE=InnoDB COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( id BIGINT NOT NULL COMMENT '计划托盘ID(雪花ID)', plan_id BIGINT NOT NULL COMMENT '入库计划ID', @@ -390,12 +408,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( KEY idx_ll_inbound_allocation_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; --- 修复既有入库计划表中的英文注释。 -ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每个租户的每个集装箱生成一张入库单'; +-- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 +ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '租户/客户账号标识', + MODIFY COLUMN source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', MODIFY COLUMN container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', MODIFY COLUMN line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 1e2c79ed6..950a00202 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; @@ -94,7 +95,8 @@ class InboundPdaWorkflowServiceTest { targetPallet.setWarehouseResourceLockId(null); targetPallet.setAssignmentStatus("PENDING_ASSIGNMENT"); when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); - when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_PLAN_PALLET", "LIP1:21", "assign-1")) + when(warehouse.reserve("100", 88L, 188L, 288L, "INBOUND_CONTAINER_DESTINATION_PALLET", "CONT-1:88", + "INBOUND_CONTAINER_DESTINATION_PALLET:CONT-1:88")) .thenReturn(999L); when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); @@ -125,6 +127,66 @@ class InboundPdaWorkflowServiceTest { targetPallet.getWarehouseLocationId(), "assign-wrong-warehouse"))); } + @Test + void cartonScanBindsOnlyExpectedCartonAndRemoveMakesItScannableAgain() { + InboundPlanCarton carton = carton("BOX-001", "UNASSIGNED", targetLine.getId(), "WH-A"); + activeTask(); + when(repository.lockCarton("100", "BOX-001")).thenReturn(carton); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target); + + var scanned = service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-001", targetPallet.getId(), "scan-1", "7", "worker")); + + assertEquals("SCANNED", scanned.result()); + assertEquals("ON_PALLET", carton.getStatus()); + assertEquals(1, carton.getVersion()); + assertEquals(1, target.getPickedPackageCount()); + verify(repository).update(carton); + + var removed = service.removeCarton("100", "LIP1", + new PdaRequests.RemoveCarton("BOX-001", "remove-1", "7", "worker")); + assertEquals("REMOVED", removed.result()); + assertEquals("UNASSIGNED", carton.getStatus()); + assertEquals(0, target.getPickedPackageCount()); + assertEquals(3, targetPallet.getActualPackageCount()); + } + + @Test + void cartonScanRejectsDuplicateAndWrongDestination() { + InboundPlanCarton repeated = carton("BOX-002", "ON_PALLET", targetLine.getId(), "WH-A"); + activeTask(); + when(repository.lockCarton("100", "BOX-002")).thenReturn(repeated); + assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-002", targetPallet.getId(), "scan-repeat", "7", "worker"))); + + InboundPlanCarton mismatched = carton("BOX-003", "UNASSIGNED", targetLine.getId(), "WH-B"); + when(repository.lockCarton("100", "BOX-003")).thenReturn(mismatched); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-003", targetPallet.getId(), "scan-destination", "7", "worker"))); + } + + @Test + void cartonScanUsesSourceInboundDetailIdWhenOrdersShareSkuAndDestination() { + InboundPlanCarton carton = carton("BOX-004", "UNASSIGNED", null, "WH-A"); + carton.setSkuCode("SKU-SHARED"); + carton.setSourceInboundDetailId("SOURCE-DETAIL-SECOND"); + activeTask(); + when(repository.lockCarton("100", "BOX-004")).thenReturn(carton); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.lineForCarton(plan.getId(), carton)).thenReturn(sourceLine); + when(repository.lockAllocation(plan.getId(), sourceLine.getId(), targetPallet.getId())).thenReturn(null); + + service.scanCarton("100", "LIP1", + new PdaRequests.ScanCarton("BOX-004", targetPallet.getId(), "scan-second-order", "7", "worker")); + + assertEquals(sourceLine.getId(), carton.getLineId()); + assertEquals(sourceLine.getOrderId(), carton.getOrderId()); + verify(repository).lineForCarton(plan.getId(), carton); + } + @Test void countMustPrecedePickAndPickedBoxesDriveActualVolume() { activeTask(); @@ -313,6 +375,7 @@ class InboundPdaWorkflowServiceTest { value.setId(1L); value.setCustomerId("100"); value.setPlanNo("LIP1"); + value.setContainerNo("CONT-1"); value.setStatus("ASSIGNED"); return value; } @@ -371,4 +434,12 @@ class InboundPdaWorkflowServiceTest { value.setVersion(0); return value; } + + private InboundPlanCarton carton(String mark, String status, Long lineId, String warehouseId) { + InboundPlanCarton value = new InboundPlanCarton(); + value.setId(41L); value.setCustomerId("100"); value.setPlanId(plan.getId()); value.setLineId(lineId); + value.setCartonMark(mark); value.setDestinationWarehouseId(warehouseId); value.setStatus(status); value.setVersion(0); + if ("ON_PALLET".equals(status)) value.setPlanPalletId(targetPallet.getId()); + return value; + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 179612fe1..a534f549d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -56,13 +56,10 @@ class InboundPlanServiceTest { } @Test - void convertsWholeContainerInOneTransaction() throws Exception { + void convertsOnlyTheSelectedInboundOrderToItsOwnPlan() throws Exception { assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class) .getAnnotation(Transactional.class)); - when(orderMapper.findContainerNo("100", 2L)).thenReturn("CONT-1"); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of( - order(1L, "RK-1", "1.5000", "WH-A"), - order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order(2L, "RK-2", "1.0000", "WH-A"))); when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) .thenReturn(1); @@ -70,26 +67,29 @@ class InboundPlanServiceTest { ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); verify(planMapper).insert(plan.capture()); - assertEquals(2, plan.getValue().getLineCount()); + assertEquals(2L, plan.getValue().getSourceOrderId()); + assertEquals(1, plan.getValue().getLineCount()); assertEquals("PENDING_ASSIGNMENT", plan.getValue().getStatus()); - assertEquals(2, plan.getValue().getPalletCount()); - assertEquals(new BigDecimal("2.5000"), plan.getValue().getTotalVolumeCbm()); - verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + assertEquals(1, plan.getValue().getPalletCount()); + assertEquals(new BigDecimal("1.0000"), plan.getValue().getTotalVolumeCbm()); + verify(palletMapper).insert(any(InboundPlanPallet.class)); ArgumentCaptor lines = ArgumentCaptor.forClass(InboundPlanLine.class); - verify(lineMapper, times(2)).insert(lines.capture()); - assertEquals(25, lines.getAllValues().stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum()); + verify(lineMapper).insert(lines.capture()); + assertEquals(10, lines.getValue().getRequiredPackageCount()); + assertEquals(2L, lines.getValue().getOrderId()); ArgumentCaptor allocations = ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); - verify(allocationMapper, times(3)).insert(allocations.capture()); - assertEquals(25, allocations.getAllValues().stream() + verify(allocationMapper).insert(allocations.capture()); + assertEquals(10, allocations.getAllValues().stream() .mapToInt(InboundPlanPalletAllocation::getPlannedPackageCount).sum()); assertEquals(0, allocations.getAllValues().stream() .mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum()); - assertEquals(3, allocations.getAllValues().stream() + assertEquals(1, allocations.getAllValues().stream() .filter(item -> "PENDING_COUNT".equals(item.getStatus())).count()); - verify(orderMapper, times(2)).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); - verify(orderMapper, times(2)).insertHistory(any(), any(), any(), any()); - verify(orderMapper).lockContainerOrders("100", "CONT-1"); + verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper).insertHistory(org.mockito.ArgumentMatchers.eq(2L), any(), any(), any()); + verify(orderMapper).lockOrder("100", 2L); + assertEquals(2L, result.sourceOrderId()); assertEquals("CONT-1", result.containerNo()); } @@ -104,21 +104,22 @@ class InboundPlanServiceTest { existing.setLineCount(1); existing.setPalletCount(1); existing.setTotalVolumeCbm(BigDecimal.ONE); - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); - when(planMapper.findByContainer("100", "CONT-1")).thenReturn(existing); + existing.setSourceOrderId(1L); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existing); - assertEquals("LIP9", service.convert("100", 1L).planNo()); + var result = service.convert("100", 1L); + assertEquals("LIP9", result.planNo()); + assertEquals(1L, result.sourceOrderId()); verify(planMapper, never()).insert(any(InboundPlan.class)); verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); } @Test void rejectsMissingDestinationWarehouse() { - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); verify(planMapper, never()).insert(any(InboundPlan.class)); @@ -126,10 +127,9 @@ class InboundPlanServiceTest { @Test void rejectsTransferWarehouseThatDiffersFromCargoSnapshots() { - when(orderMapper.findContainerNo("100", 1L)).thenReturn("CONT-1"); SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", "DESTINATION_ASSIGNED", null, BigDecimal.ONE, 1, "WH-B", "WH-B", "Warehouse B", 1, 1); - when(orderMapper.lockContainerOrders("100", "CONT-1")).thenReturn(List.of(invalid)); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(invalid)); assertThrows(ResponseStatusException.class, () -> service.convert("100", 1L)); verify(planMapper, never()).insert(any(InboundPlan.class)); 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 30aa475d0..4f3142a49 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 @@ -1,13 +1,24 @@ 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.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; 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 LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); 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)); } - @Test void createsOneOrderPerInboundAndAllSnapshots(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); 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)); } + @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(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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ + EventEnvelope source=event(); + JsonNode node=source.payload().deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + assertEquals(2,service.consume(v2)); + verify(orders,times(2)).insert(any(LastlegOrder.class)); + } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } } From 6c7a670f1e05b5b6a5fad0ec04b1a3ede66a7824 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 14:43:07 +0800 Subject: [PATCH 015/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 5 +++-- .../src/main/resources/db/phase3-ocean-booking-schema.sql | 5 +++-- .../initialization-baseline-contract.test.mjs | 5 +++++ scripts/local-db-full-acceptance.ps1 | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1b06852e8..c9b82ae1b 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9227,6 +9227,9 @@ UPDATE iam_permission SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9249,8 +9252,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102141; UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 9e50c4363..d45265ad9 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -269,6 +269,9 @@ UPDATE iam_permission SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); + INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -291,7 +294,5 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102141; UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); USE baiyue_firstleg; diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 87fd77314..2b30d9a18 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -42,6 +42,11 @@ test('三期权限迁移允许脚本历史版本写入的旧权限并在随后 assert.match(source, /SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1/) assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141/) assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102143,5102144,5102145\)/) + assert.ok( + source.indexOf('DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145)') < + source.indexOf('(5102147,0,\'PLATFORM\',\'BUTTON\',5102140,\'platform:firstleg:ocean-booking:edit\''), + 'legacy ocean-booking edit permission must be removed before assigning the code to 5102147', + ) } for (const source of [sql, truckAppointmentSchema]) { diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index cc401e801..733ad8e4a 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '8A269D219F8824352818BB427033F46F79CFB4652F36F0A9DA01A5913ABB50C6' -$ExpectedLines = 15673 +$ExpectedSha256 = '0EE13427AB60CECCEB42985CF1E78A05645C18CD9BF2886110C0AFEAF3CA7B9B' +$ExpectedLines = 15674 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.') From b55b049e777332abb289c704794fad7a90838ea6 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:20:05 +0800 Subject: [PATCH 016/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/baiyue/common/event/EventTypes.java | 1 + .../event/OverseasContainerUnloadedV1.java | 12 ++ .../OverseasContainerUnloadedV1Test.java | 24 ++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 33 ++++++ ...ernalOverseasContainerEventController.java | 40 +++++++ .../entity/ContainerBookingEntity.java | 5 + .../entity/ContainerEventInboxEntity.java | 24 ++++ .../mapper/ContainerBookingMapper.java | 18 +++ .../mapper/ContainerEventInboxMapper.java | 9 ++ .../OverseasContainerEventConsumer.java | 109 ++++++++++++++++++ .../db/phase3-smart-loading-schema.sql | 45 ++++++++ .../main/resources/openapi/firstleg-v1.yaml | 5 + .../OverseasContainerEventConsumerTest.java | 108 +++++++++++++++++ .../lastleg/entity/LastlegEventOutbox.java | 28 +++++ .../baiyue/lastleg/entity/LastlegOrder.java | 1 + .../pda/controller/InboundPdaController.java | 9 ++ .../inboundplan/pda/dto/PdaRequests.java | 6 + .../inboundplan/pda/dto/PdaResponses.java | 4 +- .../pda/entity/InboundPdaTask.java | 3 + .../service/InboundPdaWorkflowService.java | 40 ++++++- .../InboundUnloadCompletedOutboxService.java | 68 +++++++++++ .../mapper/LastlegEventOutboxMapper.java | 9 ++ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 27 +++++ .../InboundPdaWorkflowServiceTest.java | 48 ++++++++ .../inbound-measurement.mjs | 12 +- scripts/admin-test-env-modules/orders.mjs | 2 +- .../phase3-container-loading.mjs | 11 +- .../admin-test-env-modules/phase3-loading.mjs | 9 ++ scripts/db/incremental.sql | 26 +++++ 30 files changed, 727 insertions(+), 12 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerEventInboxMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index ccee66d60..7c23de9c9 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -10,6 +10,7 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java new file mode 100644 index 000000000..abf2ce607 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -0,0 +1,12 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */ +public record OverseasContainerUnloadedV1( + String containerNo, + Instant unloadingCompletedAt, + Instant unloadingPhotoUploadedAt, + Instant actualEmptyReportedAt +) { +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java new file mode 100644 index 000000000..dc847949b --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -0,0 +1,24 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasContainerUnloadedV1Test { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Test + void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { + var payload = new OverseasContainerUnloadedV1("MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + + String json = mapper.writeValueAsString(payload); + + assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); + assertThat(json).contains("MSCU1234567", "unloadingPhotoUploadedAt"); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c9b82ae1b..a4849b700 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15663,6 +15663,39 @@ CALL assert_iam_security_event_schema(); DROP PROCEDURE IF EXISTS assert_iam_security_event_schema; USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; +DELIMITER $$ +CREATE PROCEDURE add_container_empty_report_fields() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; +END$$ +DELIMITER ; +CALL add_container_empty_report_fields(); +DROP PROCEDURE IF EXISTS add_container_empty_report_fields; + +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), KEY idx_container_event_aggregate (aggregate_id,aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java new file mode 100644 index 000000000..54f1a67be --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.service.OverseasContainerEventConsumer; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequiredArgsConstructor +public class InternalOverseasContainerEventController { + private final OverseasContainerEventConsumer consumer; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping("/internal/firstleg/v1/container-events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope event) { + authenticate(token); + return ApiResponse.success(consumer.consume(event)); + } + + private void authenticate(String token) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 0d1ec3a42..59922f76e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -54,6 +54,11 @@ public class ContainerBookingEntity { private LocalDateTime actualPortArrivalAt; private LocalDateTime estimatedWarehouseArrivalAt; private LocalDateTime actualWarehouseArrivalAt; + private LocalDateTime unloadingCompletedAt; + private LocalDateTime unloadingPhotoUploadedAt; + private LocalDateTime estimatedEmptyReportedAt; + private LocalDateTime actualEmptyReportedAt; + private Long emptyReportSourceVersion; private Long createdBy; private String createdByName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java new file mode 100644 index 000000000..3fc1cec69 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerEventInboxEntity.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_container_event_inbox") +public class ContainerEventInboxEntity { + @TableId private String eventId; + private String eventType; + private Integer eventVersion; + private String aggregateId; + private Long aggregateVersion; + private String payloadHash; + private String payloadJson; + private String status; + private LocalDateTime receivedAt; + private LocalDateTime processedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 1ae3ac1da..da7d5450e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -107,6 +107,24 @@ public interface ContainerBookingMapper extends BaseMapper { +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java new file mode 100644 index 000000000..67748dd32 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -0,0 +1,109 @@ +package com.baiyue.firstleg.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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +@RequiredArgsConstructor +public class OverseasContainerEventConsumer { + private final ContainerBookingMapper containers; + private final ContainerEventInboxMapper inbox; + private final ObjectMapper objectMapper; + + @Transactional + public ContainerBookingEntity consume(EventEnvelope event) { + validateEnvelope(event); + OverseasContainerUnloadedV1 payload = payload(event); + if (payload.containerNo() == null || payload.containerNo().isBlank() + || !payload.containerNo().equals(event.aggregateId())) { + throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致"); + } + if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { + throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); + } + if (payload.actualEmptyReportedAt() != null + && payload.actualEmptyReportedAt().isBefore(payload.unloadingPhotoUploadedAt())) { + throw new IllegalArgumentException("实际报空时间不能早于卸柜照片上传时间"); + } + + ContainerEventInboxEntity existing = inbox.selectById(event.eventId()); + if (existing != null) { + if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { + throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); + } + return requiredContainer(payload.containerNo()); + } + ContainerEventInboxEntity row = inbox(event); + try { + inbox.insert(row); + } catch (DuplicateKeyException duplicate) { + ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); + if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; + return requiredContainer(payload.containerNo()); + } + + ContainerBookingEntity container = requiredContainer(payload.containerNo()); + LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); + containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, + uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + row.setStatus("PROCESSED"); + row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); + inbox.updateById(row); + return requiredContainer(payload.containerNo()); + } + + private void validateEnvelope(EventEnvelope event) { + event.requireValid(); + if (!EventTypes.OVERSEAS_CONTAINER_UNLOADED.equals(event.eventType()) || event.eventVersion() != 1) { + throw new IllegalArgumentException("不支持的海外货柜事件"); + } + if (event.sourceRegion() != RegionCode.OVERSEAS || event.targetRegion() != RegionCode.CN + || !"CONTAINER_BOOKING".equals(event.aggregateType())) { + throw new IllegalArgumentException("海外货柜事件区域或聚合类型无效"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与事件负载不一致"); + } + } + + private OverseasContainerUnloadedV1 payload(EventEnvelope event) { + try { return objectMapper.treeToValue(event.payload(), OverseasContainerUnloadedV1.class); } + catch (Exception exception) { throw new IllegalArgumentException("海外货柜事件负载无效", exception); } + } + + private ContainerEventInboxEntity inbox(EventEnvelope event) { + ContainerEventInboxEntity row = new ContainerEventInboxEntity(); + row.setEventId(event.eventId()); row.setEventType(event.eventType()); row.setEventVersion(event.eventVersion()); + row.setAggregateId(event.aggregateId()); row.setAggregateVersion(event.aggregateVersion()); + row.setPayloadHash(event.payloadHash().toLowerCase(java.util.Locale.ROOT)); + row.setPayloadJson(event.payload().toString()); row.setStatus("RECEIVED"); + row.setReceivedAt(LocalDateTime.now(ZoneOffset.UTC)); + return row; + } + + private ContainerBookingEntity requiredContainer(String containerNo) { + ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo); + if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + return value; + } + + private LocalDateTime utc(java.time.Instant value) { + return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC); + } +} diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 00e4d9dfd..5086fa25d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -72,6 +72,11 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)', + unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', + estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)', + actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)', + empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -93,6 +98,21 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0) ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', + aggregate_id VARCHAR(64) NOT NULL COMMENT '货柜聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '货柜聚合版本', + payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', + processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), + KEY idx_container_event_aggregate (aggregate_id, aggregate_version) +) ENGINE=InnoDB COMMENT='海外货柜事件消费Inbox'; + DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; DELIMITER $$ CREATE PROCEDURE phase3_container_real_unit_columns() @@ -112,6 +132,31 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL + COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL + COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL + COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL + COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL + COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 49f3f525c..6ce610218 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1525,6 +1525,11 @@ components: actualPortArrivalAt: {type: string, format: date-time, nullable: true} estimatedWarehouseArrivalAt: {type: string, format: date-time, nullable: true} actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间} + unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间} + estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算} + actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间} + emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本} planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]} exceptionActive: {type: boolean, description: 最新异常历史动作是否为 MARK} destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java new file mode 100644 index 000000000..390c16439 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -0,0 +1,108 @@ +package com.baiyue.firstleg.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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.ContainerBookingEntity; +import com.baiyue.firstleg.entity.ContainerEventInboxEntity; +import com.baiyue.firstleg.mapper.ContainerBookingMapper; +import com.baiyue.firstleg.mapper.ContainerEventInboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OverseasContainerEventConsumerTest { + @Mock private ContainerBookingMapper containers; + @Mock private ContainerEventInboxMapper inbox; + private ObjectMapper objectMapper; + private OverseasContainerEventConsumer consumer; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + consumer = new OverseasContainerEventConsumer(containers, inbox, objectMapper); + } + + @Test + void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { + ContainerBookingEntity container = container(); + when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container); + Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); + Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); + + consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, + new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null))); + + verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), + eq(LocalDateTime.parse("2026-08-20T02:30:00")), + eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); + ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); + verify(inbox).insert(captor.capture()); + assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); + assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED"); + verify(inbox).updateById(captor.getValue()); + } + + @Test + void repeatedEventWithSameHashDoesNotApplyAgain() { + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); + EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); + ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); + existing.setPayloadHash(event.payloadHash()); + when(inbox.selectById(event.eventId())).thenReturn(existing); + when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container()); + + consumer.consume(event); + + verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong()); + verify(inbox, never()).insert(any(ContainerEventInboxEntity.class)); + } + + @Test + void rejectsTamperedPayloadHash() { + EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, + new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), + Instant.parse("2026-08-20T02:30:00Z"), null)); + EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), + valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), + valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), + valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("payloadHash"); + verifyNoInteractions(containers, inbox); + } + + private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) { + var node = objectMapper.valueToTree(payload); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, + Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", + "CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null, + "trace-12345678", "correlation-12345678", null, node, hash); + } + + private ContainerBookingEntity container() { + ContainerBookingEntity value = new ContainerBookingEntity(); + value.setId(1001L); value.setContainerNo("MSCU1234567"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java new file mode 100644 index 000000000..a1179f95d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.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_event_outbox") +public class LastlegEventOutbox { + @TableId(type = IdType.INPUT) + private Long id; + private String customerId; + private String aggregateType; + private String aggregateId; + private String eventType; + private Integer eventVersion; + private String eventKey; + private String payloadJson; + private String status; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private LocalDateTime publishedAt; + private String lastError; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 4337ed4e0..903ab3afb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -56,6 +56,7 @@ public class LastlegOrder { private String warehouseRemark; private String sourceEventId; private String sourceLoadingPlanId; + private String sourceContainerBookingId; private Long sourceLoadingPlanVersion; private Long sourceHandoverVersion; private String sourcePayloadHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index b4a508bca..9cc116ea1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -105,6 +105,15 @@ public class InboundPdaController { return ApiResponse.success(service.complete(scope(user, customerId), planNo)); } + @PostMapping("/unload-photos") + public ApiResponse uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.UnloadPhotos request) { + requireOperate(user); + return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java index 685ccf911..018e04692 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -1,8 +1,11 @@ package com.baiyue.lastleg.inboundplan.pda.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; +import java.util.List; + public final class PdaRequests { private PdaRequests() {} @@ -22,4 +25,7 @@ public final class PdaRequests { public record RemoveCarton(@NotBlank String cartonMark, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + + public record UnloadPhotos(@NotEmpty List<@Positive Long> fileIds, @NotBlank String idempotencyKey, + String operatorId, String operatorName) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 7a95472ca..0c6a2bb40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -8,7 +8,9 @@ public final class PdaResponses { private PdaResponses() {} public record Task(long taskId, String taskNo, String planNo, String status, - LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt) {} + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt) {} public record Operation(String result, long allocationId, int requiredPackageCount, int countedPackageCount, int pickedPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java index 753cd2fe2..42dad8e55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java @@ -21,6 +21,9 @@ public class InboundPdaTask { private LocalDateTime pushedAt; private LocalDateTime startedAt; private LocalDateTime completedAt; + private String unloadPhotoFileIds; + private LocalDateTime unloadPhotoUploadedAt; + private LocalDateTime estimatedEmptyReturnAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; private Integer version; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index b09a218c7..d77ec080a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.core.type.TypeReference; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +45,7 @@ public class InboundPdaWorkflowService { @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; + @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -372,6 +374,11 @@ public class InboundPdaWorkflowService { InboundPdaTask task = repository.lockTask(customerId, plan.getId()); if (task == null) throw conflict("Push the inbound plan to PDA before operating"); if ("COMPLETED".equals(task.getStatus())) return task(task, planNo); + List unloadPhotoFileIds = unloadPhotoFileIds(task); + if (unloadPhotoFileIds.isEmpty() || task.getUnloadPhotoUploadedAt() == null + || task.getEstimatedEmptyReturnAt() == null) { + throw conflict("Upload unload photos before completing the PDA inbound task"); + } Map pickedByLine = new HashMap<>(); List allocations = repository.allocations(plan.getId()); for (InboundPlanPalletAllocation allocation : allocations) { @@ -402,9 +409,30 @@ public class InboundPdaWorkflowService { plan.setStatus("COMPLETED"); plan.setUpdatedAt(now); repository.update(plan); + unloadCompletedOutbox.publish(plan, task, unloadPhotoFileIds); return task(task, planNo); } + @Transactional + public PdaResponses.Task uploadUnloadPhotos(String customerId, String planNo, PdaRequests.UnloadPhotos request) { + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaOperation replay = replay(customerId, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Task.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + List fileIds = request.fileIds().stream().distinct().sorted().toList(); + if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + LocalDateTime uploadedAt = LocalDateTime.now(); + task.setUnloadPhotoFileIds(write(fileIds)); + task.setUnloadPhotoUploadedAt(uploadedAt); + task.setEstimatedEmptyReturnAt(uploadedAt.plusDays(2)); + task.setUpdatedAt(uploadedAt); + task.setVersion(value(task.getVersion()) + 1); + repository.update(task); + PdaResponses.Task result = task(task, planNo); + saveOperation(customerId, task.getId(), null, "UPLOAD_UNLOAD_PHOTOS", request.idempotencyKey(), request, result); + return result; + } + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, int packageCount, BigDecimal movedVolume) { source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); @@ -582,7 +610,17 @@ public class InboundPdaWorkflowService { private PdaResponses.Task task(InboundPdaTask task, String planNo) { return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), - task.getPushedAt(), task.getStartedAt(), task.getCompletedAt()); + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + } + + private List unloadPhotoFileIds(InboundPdaTask task) { + if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of(); + try { + return objectMapper.readValue(task.getUnloadPhotoFileIds(), new TypeReference>() {}); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot read unload photo file IDs", exception); + } } private BigDecimal perPackageVolume(InboundPlanLine line) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java new file mode 100644 index 000000000..5a502dab5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** Writes the overseas-to-CN handover to the local transactional outbox. */ +@Service +public class InboundUnloadCompletedOutboxService { + @Resource private LastlegEventOutboxMapper mapper; + @Resource private LastlegOrderMapper orders; + @Resource private ObjectMapper objectMapper; + + public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { + LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); + if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null + || sourceOrder.getSourceContainerBookingId().isBlank()) { + throw new IllegalStateException("Inbound plan has no source container booking ID"); + } + Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); + var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), + plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); + JsonNode payloadNode = objectMapper.valueToTree(payload); + long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion()); + String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); + String traceId = EventIdentifiers.newUuid(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, + 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", + sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); + + LastlegEventOutbox event = new LastlegEventOutbox(); + event.setId(IdWorker.getId()); event.setCustomerId(task.getCustomerId()); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); + event.setEventKey(eventKey); event.setPayloadJson(json(envelope)); event.setStatus("PENDING"); + event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + mapper.insert(event); + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize unload-completed event", exception); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java new file mode 100644 index 000000000..c7871d177 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegEventOutboxMapper extends BaseMapper { +} 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 3d076a2b3..cf841602a 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 @@ -120,7 +120,8 @@ public class LoadingCompletedConsumer { order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); - order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); + order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); try { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 67935ab04..8e3682ab8 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', source_handover_version BIGINT COMMENT '交接版本', source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -163,6 +164,8 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history ( -- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; ALTER TABLE ll_order COMMENT='尾程订单主表'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -237,6 +240,7 @@ ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', + MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -410,6 +414,8 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( -- 修复既有入库计划表的注释;历史“每柜一张计划”记录不在这里猜测拆分。 ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', @@ -491,12 +497,33 @@ CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(255) NULL COMMENT '操作人姓名', pushed_at DATETIME(3) NULL COMMENT '任务下发时间', started_at DATETIME(3) NULL COMMENT '开始时间', completed_at DATETIME(3) NULL COMMENT '完成时间', + unload_photo_file_ids JSON NULL COMMENT '卸柜照片文件ID列表', unload_photo_uploaded_at DATETIME(3) NULL COMMENT '卸柜照片上传成功时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', version INT 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 '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_inbound_pda_task_plan (customer_id, plan_id), UNIQUE KEY uk_ll_inbound_pda_task_no (task_no) ) ENGINE=InnoDB COMMENT='入仓PDA作业任务'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE inbound_pda_photo_stmt FROM @ddl; EXECUTE inbound_pda_photo_stmt; DEALLOCATE PREPARE inbound_pda_photo_stmt; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 950a00202..6caff0876 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -22,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -42,6 +43,7 @@ class InboundPdaWorkflowServiceTest { @Mock PdaWorkflowRepository repository; @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; + @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -61,6 +63,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); + ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -300,6 +303,47 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).update(plan); } + @Test + void completionRequiresSuccessfullyUploadedUnloadPhotos() { + activeTask(); + task.setUnloadPhotoFileIds(null); + task.setUnloadPhotoUploadedAt(null); + task.setEstimatedEmptyReturnAt(null); + + assertThrows(ResponseStatusException.class, () -> service.complete("100", "LIP1")); + + verify(repository, never()).allocations(plan.getId()); + verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); + } + + @Test + void uploadUnloadPhotosRecordsEstimatedEmptyReturnTwoDaysLaterAndIsIdempotent() { + activeTask(); + PdaRequests.UnloadPhotos request = new PdaRequests.UnloadPhotos(List.of(103L, 101L), + "unload-photo-1", "7", "worker"); + + var result = service.uploadUnloadPhotos("100", "LIP1", request); + + assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); + assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); + verify(repository).update(task); + ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); + verify(repository).insert(operation.capture()); + when(repository.operation("100", "unload-photo-1")).thenReturn(operation.getValue()); + + var replay = service.uploadUnloadPhotos("100", "LIP1", request); + assertEquals(result, replay); + verify(repository, times(1)).update(task); + } + + @Test + void uploadUnloadPhotosRejectsDuplicateFileIds() { + activeTask(); + assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1", + new PdaRequests.UnloadPhotos(List.of(101L, 101L), "unload-photo-duplicate", "7", "worker"))); + verify(repository, never()).update(task); + } + @Test void completesWhenMergedAllocationsPreserveExactLineTotals() { activeTask(); @@ -316,6 +360,7 @@ class InboundPdaWorkflowServiceTest { verify(repository).update(plan); verify(stockReceipts).stockIn(eq("100"), eq(plan), eq(task), eq(List.of(targetLine)), eq(List.of(target, moved)), any(), any()); + verify(unloadCompletedOutbox).publish(eq(plan), eq(task), eq(List.of(101L))); } @Test @@ -387,6 +432,9 @@ class InboundPdaWorkflowServiceTest { value.setCustomerId("100"); value.setTaskNo("PDA2"); value.setStatus("PDA_PENDING"); + value.setUnloadPhotoFileIds("[101]"); + value.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); + value.setEstimatedEmptyReturnAt(LocalDateTime.of(2026, 8, 22, 10, 0)); value.setVersion(0); return value; } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 32e0da358..c19b7765f 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,10 +1,12 @@ -import { readFile } from 'node:fs/promises' - const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const WEIGHING_IMAGE_COUNT = 1 const FIXTURE_PALLET_CAPACITY_CBM = 1.2 +const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, + 8, 215, 99, 248, 207, 192, 240, 31, 0, 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, + 73, 69, 78, 68, 174, 66, 96, 130]) function asId(value) { return value == null ? '' : String(value) } function asNumber(value) { return Number(value) } @@ -77,10 +79,9 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa } async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { - const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url)) const uploadResponse = await context.requestMultipart('POST', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { - content: image, type: 'image/png', name: `weight-${inboundId}.png`, + content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`, }) const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, `inboundId=${inboundId}`, OPTIONS) @@ -153,8 +154,7 @@ function findIsolatedInbound(context) { async function multipartRequest(context, path, data) { const form = new FormData() form.append('data', new Blob([JSON.stringify(data)], { type: 'application/json' })) - const image = await readFile(new URL('../../../apps/pda-uniapp/static/c7.png', import.meta.url)) - form.append('image', new Blob([image], { type: 'image/png' }), 'carton-evidence.png') + form.append('image', new Blob([WEIGHT_IMAGE], { type: 'image/png' }), 'carton-evidence.png') try { const response = await fetch(`${context.options.gateway}${path}`, { method: 'POST', diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index b0bf0ce9e..a7a7c3fd8 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -17,7 +17,7 @@ const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) => function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) } -function success(response) { return response.http === 200 && String(response.body?.code) === '200' } +function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 188cbf5ae..8c24db7ff 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -144,6 +144,13 @@ export async function run(context) { OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName + const supplierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) + const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, + Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) + if (!supplier?.id) return const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 } const realContainerNo = `TST-${suffix}-01`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) @@ -151,8 +158,8 @@ export async function run(context) { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: null, - bookingAgentId: null, bookingAgentName: null, + shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c1dc96751..9f8be083d 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -123,11 +123,20 @@ export async function run(context) { const containerNo = `P3B-${suffix}`.slice(0, 64) const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading' + const supplierResponse = await context.request('GET', + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) + const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, + Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) + if (!supplier?.id) return const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), + shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 8253ca30c..298df8d32 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3551,3 +3551,29 @@ PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE P -- ============================================================================ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='sub_order_status'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN sub_order_status VARCHAR(255) NULL COMMENT ''子单据状态汇总'' AFTER current_status')); PREPARE ll_order_sub_status_026 FROM @ddl; EXECUTE ll_order_sub_status_026; DEALLOCATE PREPARE ll_order_sub_status_026; + +-- ============================================================================ +-- 2026-08-20 / 027 +-- One tail-leg inbound plan per source tail-leg order; one container may carry many plans. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NULL COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); +PREPARE ll_inbound_plan_source_order_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_027; + +-- Legacy plans were created per container and cannot be inferred back to a source order. +UPDATE ll_inbound_plan SET source_order_id=NULL WHERE source_order_id=0; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE ll_inbound_plan_drop_container_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_drop_container_unique_027; DEALLOCATE PREPARE ll_inbound_plan_drop_container_unique_027; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_source_order'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); +PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; From cd34cd426e96cad3ba8f7c3c6c947e2791a27b00 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:25:40 +0800 Subject: [PATCH 017/231] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/InternalBusinessFileService.java | 3 +- .../InternalBusinessFileServiceTest.java | 8 +++ .../OverseasContainerEventConsumerTest.java | 2 +- .../controller/LastlegHealthController.java | 8 +-- .../controller/LastlegOrderController.java | 20 ++++-- .../DeliveryTransferController.java | 13 +++- .../controller/DispatchController.java | 23 +++--- .../lastleg/dto/LastlegOrderSummary.java | 2 +- .../controller/InboundPlanController.java | 17 +++-- .../InboundUnloadCompletedOutboxService.java | 7 +- .../InventoryCommandController.java | 23 +++--- .../controller/InventoryQueryController.java | 20 ++++-- ...boundUnloadCompletedOutboxServiceTest.java | 71 +++++++++++++++++++ .../service/LoadingCompletedConsumerTest.java | 2 +- 14 files changed, 170 insertions(+), 49 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 96faadfc4..d320f4aaa 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -22,7 +22,8 @@ public class InternalBusinessFileService { "CONTAINER_CLEARANCE_DOCUMENT", "LOADING_CUSTOMS_DOCUMENT", "CONTAINER_DELIVERY_ORDER", - "CONTAINER_PICKUP_ORDER"); + "CONTAINER_PICKUP_ORDER", + "OVERSEAS_UNLOAD_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 44fd69118..7b8c7bcd6 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -40,6 +40,8 @@ class InternalBusinessFileServiceTest { when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); + when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; }); @@ -48,6 +50,7 @@ class InternalBusinessFileServiceTest { var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); + var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); assertThat(clearance).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, @@ -57,8 +60,13 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); + assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51"); verify(storage, times(3)).storeLoading(0L, 41L, file); verify(storage).storeLoading(0L, 51L, file); + verify(storage).storeLoading(11L, 51L, file); } @Test void rejectsNegativeTenantAndInvalidOwnershipInputs() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index 390c16439..772830e94 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -55,7 +55,7 @@ class OverseasContainerEventConsumerTest { eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L)); ArgumentCaptor captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class); verify(inbox).insert(captor.capture()); - assertThat(captor.getValue().getAggregateId()).isEqualTo("1001"); + assertThat(captor.getValue().getAggregateId()).isEqualTo("MSCU1234567"); assertThat(captor.getValue().getStatus()).isEqualTo("PROCESSED"); verify(inbox).updateById(captor.getValue()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java index abb4a10cb..0e8e98834 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -13,15 +13,15 @@ import java.util.Map; * 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。 */ @Tag( - name = "Last-leg service status / 尾程服务状态", - description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。" + name = "尾程服务状态", + description = "尾程服务可用性接口" ) @RestController @RequestMapping("/api/platform/v1/last-leg") public class LastlegHealthController { @Operation( - summary = "Get service status / 查询服务状态", - description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。" + summary = "查询服务状态", + description = "返回尾程服务边界的逻辑运行状态" ) @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", 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 d3373c2db..5543eae2e 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 @@ -32,14 +32,15 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @RestController -@Tag(name = "Tail-leg orders") +@Tag(name = "尾程订单") public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private LastlegOrderService orderService; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping("/internal/lastleg/events") - @Operation(summary = "Receive a first-leg loading-completed event") + /** 接收头程装柜完成事件并创建尾程订单。 */ + @Operation(summary = "接收头程装柜完成事件") public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { @@ -48,7 +49,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders") - @Operation(summary = "Page tail-leg order rows") + /** 分页查询尾程订单列表。 */ + @Operation(summary = "分页查询尾程订单") public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -57,7 +59,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders/{id}") - @Operation(summary = "Get one inbound-code-level tail-leg order") + /** 查询指定入仓单维度的尾程订单详情。 */ + @Operation(summary = "查询尾程订单详情") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -66,7 +69,8 @@ public class LastlegOrderController { } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") - @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + /** 按筛选条件导出尾程订单 CSV 文件。 */ + @Operation(summary = "导出尾程订单") public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -79,7 +83,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") - @Operation(summary = "Transfer one order to a destination warehouse") + /** 将单个尾程订单转移到目标仓库。 */ + @Operation(summary = "单个订单转移仓库") public ApiResponse transferOne( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -89,7 +94,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/destination") - @Operation(summary = "Transfer multiple orders to a destination warehouse") + /** 将多个尾程订单批量转移到目标仓库。 */ + @Operation(summary = "批量订单转移仓库") public ApiResponse transferBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 49088ef0a..5425714e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -17,12 +17,13 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg") -@Tag(name = "Outbound and delivery") +@Tag(name = "出库与派送") public class DeliveryTransferController { @Resource private DeliveryTransferService service; @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") - @Operation(summary = "Create one pending outbound order and one pending delivery order") + /** 创建一个待处理出库单和一个待派送单。 */ + @Operation(summary = "创建出库与派送单") public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @RequestParam(required = false) String customerId, @@ -30,28 +31,36 @@ public class DeliveryTransferController { return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request)); } + /** 分页查询出库单。 */ @GetMapping("/outbound-orders") + @Operation(summary = "分页查询出库单") public ApiResponse> outboundPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { return ApiResponse.success(service.outboundPage(scope(user, customerId), query)); } + /** 查询出库单详情及托盘信息。 */ @GetMapping("/outbound-orders/{outboundNo}") + @Operation(summary = "查询出库单详情") public ApiResponse outboundDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query)); } + /** 分页查询派送单。 */ @GetMapping("/delivery-orders") + @Operation(summary = "分页查询派送单") public ApiResponse> deliveryPage( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid DeliveryRequests.Query query) { return ApiResponse.success(service.deliveryPage(scope(user, customerId), query)); } + /** 查询派送单详情及托盘信息。 */ @GetMapping("/delivery-orders/{deliveryNo}") + @Operation(summary = "查询派送单详情") public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 9cf6b7f44..dad601018 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -32,13 +32,14 @@ import java.util.List; @RestController @RequestMapping("/api/platform/v1/last-leg/dispatches") -@Tag(name = "Tail-leg vehicle dispatch") +@Tag(name = "尾程派车") public class DispatchController { @Resource private DispatchService service; @Resource private DispatchPdaWorkflowService pdaWorkflow; @GetMapping("/candidates") - @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch") + /** 查询可用于智能预派车的未锁定托盘。 */ + @Operation(summary = "查询可派车托盘") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -49,7 +50,8 @@ public class DispatchController { } @PostMapping("/pre-arrange") - @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") + /** 创建待出发派车单并锁定选中的托盘。 */ + @Operation(summary = "创建预派车单") public ApiResponse preArrange( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -60,7 +62,8 @@ public class DispatchController { } @GetMapping - @Operation(summary = "Page dispatch orders") + /** 分页查询派车单。 */ + @Operation(summary = "分页查询派车单") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -69,7 +72,8 @@ public class DispatchController { } @GetMapping("/{dispatchNo}") - @Operation(summary = "Get dispatch details and all pallet destinations") + /** 查询派车单详情及托盘目的地。 */ + @Operation(summary = "查询派车单详情") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -78,7 +82,8 @@ public class DispatchController { } @PutMapping("/{dispatchNo}") - @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") + /** 编辑车辆、司机、预约、路线和出发信息。 */ + @Operation(summary = "编辑派车单") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -88,7 +93,8 @@ public class DispatchController { } @DeleteMapping("/{dispatchNo}") - @Operation(summary = "Cancel a pending dispatch and release its pallet locks") + /** 取消待处理派车单并释放托盘锁定。 */ + @Operation(summary = "取消派车单") public ApiResponse cancel( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -98,7 +104,8 @@ public class DispatchController { } @PostMapping("/{dispatchNo}/depart") - @Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed") + /** 出库 PDA 已拣货托盘并将派车单标记为已出发。 */ + @Operation(summary = "PDA 出库并完成派车") public ApiResponse depart( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 0ae6357f7..390c984f7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.time.LocalDateTime; -@Schema(description = "Tail-leg order row at inbound-code granularity") +@Schema(description = "按入仓单编码粒度展示的尾程订单列表行") public record LastlegOrderSummary( Long id, String orderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3e9cb5d66..c5ac307ac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -20,20 +20,22 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/inbound-plans") -@Tag(name = "Tail-leg inbound plans") +@Tag(name = "尾程入仓计划") public class InboundPlanController { @Resource private InboundPlanService service; @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") - @Operation(summary = "Convert one inbound order to one inbound plan") + /** 将入仓单转换为入仓计划。 */ + @Operation(summary = "入仓单转入仓计划") public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); } @GetMapping("/{planNo}") - @Operation(summary = "Get an inbound plan with lines, pallets and allocations") + /** 查询入仓计划及其明细、托盘和分配信息。 */ + @Operation(summary = "查询入仓计划详情") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @RequestParam(required = false) String customerId) { @@ -41,7 +43,8 @@ public class InboundPlanController { } @PostMapping("/{planNo}/pallets/supplement") - @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse") + /** 为计划目标仓新增一个可打印的补充托盘。 */ + @Operation(summary = "新增补充托盘") public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @Valid @RequestBody SupplementPalletRequest request) { @@ -49,7 +52,8 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") - @Operation(summary = "Create one FBA appointment for selected inbound lines") + /** 为选中的入仓明细创建 FBA 预约。 */ + @Operation(summary = "创建 FBA 预约") public ApiResponse batchFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody BatchFbaAppointmentRequest request) { @@ -59,7 +63,8 @@ public class InboundPlanController { } @GetMapping("/fba-appointments/{appointmentNo}") - @Operation(summary = "Get an FBA appointment and its selected inbound lines") + /** 查询 FBA 预约及其选中的入仓明细。 */ + @Operation(summary = "查询 FBA 预约详情") public ApiResponse fbaAppointmentDetail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String appointmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 5a502dab5..a4a95a21c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -39,10 +39,11 @@ public class InboundUnloadCompletedOutboxService { throw new IllegalStateException("Inbound plan has no source container booking ID"); } Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); - var payload = new OverseasContainerUnloadedV1(sourceOrder.getSourceContainerBookingId(), - plan.getContainerNo(), photoUploadedAt, photoUploadedAt, null); + var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt, + photoUploadedAt, null); JsonNode payloadNode = objectMapper.valueToTree(payload); - long aggregateVersion = Math.max(1, task.getVersion() == null ? 1 : task.getVersion()); + // Multiple inbound plans can share one container; upload epoch keeps their container events ordered. + long aggregateVersion = photoUploadedAt.toEpochMilli(); String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 71d44c87b..4ae6bc836 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -19,14 +19,15 @@ import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/inventory") -@Tag(name = "Tail-leg inbound-order package inventory commands") +@Tag(name = "尾程入仓单库存操作") public class InventoryCommandController { public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; @Resource private InventoryCommandService service; @PostMapping("/hold") - @Operation(summary = "Move available inbound-order packages to hold") + /** 将可用入仓单箱数转为冻结。 */ + @Operation(summary = "冻结入仓单库存") public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -34,7 +35,8 @@ public class InventoryCommandController { } @PostMapping("/release") - @Operation(summary = "Release held inbound-order packages") + /** 释放已冻结的入仓单箱数。 */ + @Operation(summary = "释放入仓单库存") public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -42,7 +44,8 @@ public class InventoryCommandController { } @PostMapping("/allocate") - @Operation(summary = "Allocate available inbound-order packages to an outbound order") + /** 将可用入仓单箱数分配给出库单。 */ + @Operation(summary = "分配出库库存") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -50,7 +53,8 @@ public class InventoryCommandController { } @PostMapping("/unallocate") - @Operation(summary = "Cancel an outbound allocation") + /** 取消出库库存分配。 */ + @Operation(summary = "取消出库分配") public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -58,7 +62,8 @@ public class InventoryCommandController { } @PostMapping("/pick") - @Operation(summary = "Confirm picked inbound-order packages") + /** 确认入仓单箱数已拣货。 */ + @Operation(summary = "确认拣货") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -66,7 +71,8 @@ public class InventoryCommandController { } @PostMapping("/ship") - @Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty") + /** 出库已拣货箱数并返回托盘是否为空。 */ + @Operation(summary = "出库拣货库存") public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -74,7 +80,8 @@ public class InventoryCommandController { } @PostMapping("/adjust") - @Operation(summary = "Adjust inbound-order package inventory after a physical count") + /** 根据实物盘点结果调整入仓单库存。 */ + @Operation(summary = "盘点调整库存") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Adjustment request) { requirePermission(user, ADJUST_PERMISSION); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index f9b9f759d..3b5bc49e9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -28,14 +28,15 @@ import java.nio.charset.StandardCharsets; import java.util.List; @RestController -@Tag(name = "Inventory queries") +@Tag(name = "库存查询") public class InventoryQueryController { private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; @Resource private InventoryQueryService service; @GetMapping(BASE_PATH + "/summary") - @Operation(summary = "Get inbound-order package inventory totals") + /** 查询入仓单库存汇总。 */ + @Operation(summary = "查询库存汇总") public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -44,7 +45,8 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances") - @Operation(summary = "Page inventory by customer, warehouse and inbound order") + /** 按客户、仓库和入仓单分页查询库存。 */ + @Operation(summary = "分页查询库存余额") public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -53,7 +55,8 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") - @Operation(summary = "List pallet and batch positions for one inbound-order balance") + /** 查询库存余额对应的托盘和批次位置。 */ + @Operation(summary = "查询库存位置") public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -63,7 +66,8 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/ledger") - @Operation(summary = "Page append-only inventory movements") + /** 分页查询只增不改的库存流水。 */ + @Operation(summary = "分页查询库存流水") public ApiResponse> ledgers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -72,7 +76,8 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") - @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV") + /** 导出筛选后的入仓单库存 CSV 文件。 */ + @Operation(summary = "导出库存余额") public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -81,7 +86,8 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") - @Operation(summary = "Export filtered inventory movements as UTF-8 CSV") + /** 导出筛选后的库存流水 CSV 文件。 */ + @Operation(summary = "导出库存流水") public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java new file mode 100644 index 000000000..3720afd97 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundUnloadCompletedOutboxServiceTest { + @Mock LastlegEventOutboxMapper outbox; + @Mock LastlegOrderMapper orders; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private InboundUnloadCompletedOutboxService service; + + @BeforeEach + void setUp() { + service = new InboundUnloadCompletedOutboxService(); + ReflectionTestUtils.setField(service, "mapper", outbox); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + } + + @Test + void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception { + InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1"); + InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); + task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3); + LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001"); + when(orders.selectById(11L)).thenReturn(order); + + service.publish(plan, task, List.of(101L)); + + ArgumentCaptor saved = ArgumentCaptor.forClass(LastlegEventOutbox.class); + verify(outbox).insert(saved.capture()); + EventEnvelope envelope = objectMapper.readValue(saved.getValue().getPayloadJson(), EventEnvelope.class); + assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType()); + assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion()); + assertEquals(RegionCode.CN, envelope.targetRegion()); + assertEquals("9001", envelope.aggregateId()); + assertEquals("MSCU1", envelope.payload().path("containerNo").asText()); + } + + @Test + void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() { + InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); + InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); + when(orders.selectById(11L)).thenReturn(new LastlegOrder()); + assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L))); + } +} 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 4f3142a49..cab178bdb 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 @@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); 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)); } - @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(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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(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->"book-1".equals(o.getSourceContainerBookingId()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } From 2847ad1b2d1339607be4500756906189b5778024 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:58:25 +0800 Subject: [PATCH 018/231] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/event-bridge-service.yml | 2 + config/nacos/overseas/lastleg-service.yml | 6 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 65 +++++++++++++++ .../eventbridge/mapper/BridgeInboxMapper.java | 5 +- .../service/FirstlegEventClient.java | 7 ++ .../service/HttpFirstlegEventClient.java | 52 ++++++++++++ .../service/OverseasEventDispatcher.java | 10 ++- .../src/main/resources/application.yml | 1 + .../service/OverseasEventDispatcherTest.java | 17 ++++ .../main/resources/db/lastleg-permissions.sql | 83 +++++++++++++++---- .../LastlegPermissionSqlContractTest.java | 37 +++++++++ .../lastleg/BaiyueLastlegApplication.java | 2 + .../controller/LastlegOrderController.java | 6 ++ .../controller/DispatchController.java | 7 ++ .../lastleg/entity/LastlegEventOutbox.java | 2 + .../baiyue/lastleg/entity/LastlegOrder.java | 1 - .../controller/InboundPlanController.java | 6 ++ .../pda/controller/InboundPdaController.java | 12 +++ .../inboundplan/pda/dto/PdaResponses.java | 2 + .../gateway/HttpUnloadPhotoFileGateway.java | 79 ++++++++++++++++++ .../pda/gateway/UnloadPhotoFileGateway.java | 10 +++ .../service/InboundPdaWorkflowService.java | 41 +++++++++ .../InboundUnloadCompletedOutboxService.java | 17 ++-- .../InventoryCommandController.java | 12 ++- .../controller/InventoryQueryController.java | 7 ++ .../mapper/LastlegEventOutboxMapper.java | 40 +++++++++ ...PlatformOverseasProcurementController.java | 8 +- .../service/OverseasProcurementService.java | 4 +- .../RelabelApplicationController.java | 5 ++ .../service/ContainerUnloadOutboxRelay.java | 56 +++++++++++++ .../lastleg/service/EventBridgePublisher.java | 39 +++++++++ .../service/LoadingCompletedConsumer.java | 3 +- .../PlatformUsTruckOrderController.java | 15 +++- .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/application.yml | 3 + .../src/main/resources/db/lastleg.sql | 7 +- ...LastlegPlatformPermissionContractTest.java | 57 +++++++++++++ .../InboundPdaWorkflowServiceTest.java | 57 ++++++++++++- ...boundUnloadCompletedOutboxServiceTest.java | 25 ++---- .../ContainerUnloadOutboxRelayTest.java | 57 +++++++++++++ .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/db/incremental.sql | 67 +++++++++++++++ .../PlatformWarehouseController.java | 6 ++ .../PlatformWarehouseResourceController.java | 11 +++ ...atformWarehousePermissionContractTest.java | 36 ++++++++ 45 files changed, 921 insertions(+), 68 deletions(-) create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index a5fbba087..0738b8d61 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,5 @@ baiyue: region: ${REGION_CODE:CN} deployment-cell: ${DEPLOYMENT_CELL:prod-cn} data-residency: ${DATA_RESIDENCY:CN} + event-dispatcher: + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg-service:9203/internal/firstleg/v1/container-events} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 1b88f047a..68fbcefb3 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -4,3 +4,9 @@ spring: username: ${LASTLEG_DB_USERNAME} password: ${LASTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + file: + url: ${FILE_SERVICE_URL} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4849b700..c5488e94f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15701,6 +15701,71 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; +-- 2026-08-20 / 海外仓现有业务平台权限目录。 +USE baiyue_iam; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +CREATE TEMPORARY TABLE tmp_lastleg_role_grant ( + tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_code) +); +INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code) +SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code +FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.id BETWEEN 5106000 AND 5106004 + AND permission.permission_code IN ('platform:lastleg:order:list','platform:lastleg:order:transfer'); +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%') + OR permission.permission_code IN ('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet'); +DELETE FROM iam_permission +WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%') + OR (tenant_id=0 AND app_code='PLATFORM' AND permission_code IN + ('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet')); +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:lastleg','海外仓业务','/lastleg',NULL,700,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',5107000,'platform:lastleg:order:list','尾程订单','/lastleg/orders','GET',10,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:export','导出尾程订单',NULL,'GET',10,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:transfer','尾程订单转仓',NULL,'PUT',20,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',5107000,'platform:lastleg:inbound-plan:list','入库单','/lastleg/inbound-plans','GET',20,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:create','新建入库单',NULL,'POST',10,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:edit','维护入库单',NULL,'POST',20,'ACTIVE',1,0), + (5107023,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:appointment','FBA预约',NULL,'POST',30,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',5107000,'platform:lastleg:relabel:list','换标单','/lastleg/relabels','GET',30,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:create','新增换标单',NULL,'POST',10,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:status','操作换标状态',NULL,'PUT',20,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',5107000,'platform:lastleg:inventory:list','库存管理','/lastleg/inventory','GET',40,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:export','导出库存',NULL,'GET',10,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:manage','库存业务操作',NULL,'POST',20,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:adjust','库存盘点调整',NULL,'POST',30,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',5107000,'platform:lastleg:dispatch:list','排车管理','/lastleg/dispatches','GET',50,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:create','创建预排车单',NULL,'POST',10,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:edit','编辑排车单',NULL,'PUT',20,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:delete','取消排车单',NULL,'DELETE',30,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',5107000,'platform:lastleg:overseas-procurement:list','海外采购','/lastleg/overseas-procurements','GET',60,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:edit','编辑海外采购单',NULL,'PATCH',10,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:review','审核海外采购单',NULL,'POST',20,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:outbound','登记采购出库',NULL,'POST',30,'ACTIVE',1,0), + (5107070,0,'PLATFORM','MENU',5107000,'platform:lastleg:us-trucking:list','本土卡车','/lastleg/us-truck-orders','GET',70,'ACTIVE',1,0), + (5107071,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:status','操作卡车订单状态',NULL,'PATCH',10,'ACTIVE',1,0), + (5107072,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:receive','登记卡车收托',NULL,'POST',20,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),permission_type=VALUES(permission_type), + route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; +INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT saved.tenant_id,saved.role_id,permission.id,NOW(3) +FROM tmp_lastleg_role_grant saved +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=saved.permission_code; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +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 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 + AND (permission.permission_code='platform:lastleg' OR permission.permission_code LIKE 'platform:lastleg:%'); + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index f93a34ff2..568192832 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -16,8 +16,7 @@ import java.util.List; public interface BridgeInboxMapper extends BaseMapper { @Select(""" SELECT event_id FROM bridge_inbox - WHERE target_region = 'OVERSEAS' - AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) ORDER BY received_at LIMIT #{limit} """) @@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper { @Update(""" UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) - WHERE event_id=#{eventId} AND target_region='OVERSEAS' + WHERE event_id=#{eventId} AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) """) diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java new file mode 100644 index 000000000..a0bb0e33e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FirstlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java new file mode 100644 index 000000000..0aacdc3f3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -0,0 +1,52 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpFirstlegEventClient implements FirstlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") + private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 76c1bf82f..b795a7019 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -20,6 +20,7 @@ public class OverseasEventDispatcher { @Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; + @Resource private FirstlegEventClient firstlegEventClient; @Resource private EventDispatcherProperties properties; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @@ -40,7 +41,7 @@ public class OverseasEventDispatcher { int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); try { - String responseCode = eventClient.deliver(event); + String responseCode = deliver(event); if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { throw new IllegalStateException("事件租约已失效,无法确认投递结果"); } @@ -49,11 +50,16 @@ public class OverseasEventDispatcher { String error = abbreviate(exception.getMessage(), 1000); inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); - log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, error); } } + private String deliver(BridgeInbox event) throws Exception { + return "CN".equals(event.getTargetRegion()) + ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + } + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 53a1bdc39..de98ed4c6 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -32,6 +32,7 @@ baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/firstleg/v1/container-events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index f3f727fb0..67cb7b3d1 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -21,6 +21,7 @@ class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; @@ -34,6 +35,7 @@ class OverseasEventDispatcherTest { ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); event = new BridgeInbox(); event.setEventId("event-1"); @@ -83,9 +85,24 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); } + @Test + void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(firstlegEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(firstlegEventClient).deliver(event); + verifyNoInteractions(eventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + private BridgeDeliveryAttempt updatedAttempt() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 98348b610..6d8bd5033 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -1,25 +1,78 @@ USE baiyue_iam; --- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。 +-- 海外业务权限使用独立 5107000 号段,避免与消息中心 5106000 号段冲突。 +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +CREATE TEMPORARY TABLE tmp_lastleg_role_grant ( + tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_code) +); +INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code) +SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code +FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id = role_permission.permission_id +WHERE permission.id BETWEEN 5106000 AND 5106004 + AND permission.permission_code IN ('platform:lastleg:order:list', 'platform:lastleg:order:transfer'); + +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id = role_permission.permission_id +WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%') + OR permission.permission_code IN ( + 'platform:lastleg:order:detail', 'platform:lastleg:order:assign', + 'platform:lastleg:order:merge-pallet'); +DELETE FROM iam_permission +WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%') + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN ( + 'platform:lastleg:order:detail', 'platform:lastleg:order:assign', + 'platform:lastleg:order:merge-pallet')); + INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), - (5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), - (5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), - (5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), - (5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), - http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + (5107000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg', '海外仓业务', '/lastleg', NULL, 700, 'ACTIVE', 1, 0), + (5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 10, 'ACTIVE', 1, 0), + (5107011, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:export', '导出尾程订单', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5107012, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:transfer', '尾程订单转仓', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list', '入库单', '/lastleg/inbound-plans', 'GET', 20, 'ACTIVE', 1, 0), + (5107021, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:create', '新建入库单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107022, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:edit', '维护入库单', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107023, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:appointment', 'FBA预约', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107030, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:relabel:list', '换标单', '/lastleg/relabels', 'GET', 30, 'ACTIVE', 1, 0), + (5107031, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:create', '新增换标单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107032, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:status', '操作换标状态', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107040, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inventory:list', '库存管理', '/lastleg/inventory', 'GET', 40, 'ACTIVE', 1, 0), + (5107041, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:export', '导出库存', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5107042, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:manage', '库存业务操作', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107043, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:adjust', '库存盘点调整', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107050, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:dispatch:list', '排车管理', '/lastleg/dispatches', 'GET', 50, 'ACTIVE', 1, 0), + (5107051, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:create', '创建预排车单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107052, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:edit', '编辑排车单', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107053, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:delete', '取消排车单', NULL, 'DELETE', 30, 'ACTIVE', 1, 0), + (5107060, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:overseas-procurement:list', '海外采购', '/lastleg/overseas-procurements', 'GET', 60, 'ACTIVE', 1, 0), + (5107061, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:edit', '编辑海外采购单', NULL, 'PATCH', 10, 'ACTIVE', 1, 0), + (5107062, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:review', '审核海外采购单', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107063, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:outbound', '登记采购出库', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107070, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:us-trucking:list', '本土卡车', '/lastleg/us-truck-orders', 'GET', 70, 'ACTIVE', 1, 0), + (5107071, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:status', '操作卡车订单状态', NULL, 'PATCH', 10, 'ACTIVE', 1, 0), + (5107072, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:receive', '登记卡车收托', NULL, 'POST', 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT saved.tenant_id, saved.role_id, permission.id, NOW(3) +FROM tmp_lastleg_role_grant saved +JOIN iam_permission permission ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' AND permission.permission_code = saved.permission_code; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; 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 -JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' -WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' - AND role.status='ACTIVE' AND role.deleted=0 - AND permission.permission_code IN ( - 'platform:lastleg:order:list', 'platform:lastleg:order:detail', - 'platform:lastleg:order:assign', 'platform:lastleg:order:transfer', - 'platform:lastleg:order:merge-pallet'); +JOIN iam_permission permission ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM' +WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code = 'PLATFORM_ADMIN' + AND role.status = 'ACTIVE' AND role.deleted = 0 + AND (permission.permission_code = 'platform:lastleg' + OR permission.permission_code LIKE 'platform:lastleg:%'); diff --git a/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java new file mode 100644 index 000000000..bd47285d5 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegPermissionSqlContractTest { + @Test + void registersExistingOverseasBusinessesAndMigratesLegacyIds() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains( + "tmp_lastleg_role_grant", "id BETWEEN 5106000 AND 5106004", + "(5107000, 0, 'PLATFORM', 'MENU'", + "platform:lastleg:order:list", "platform:lastleg:inbound-plan:list", + "platform:lastleg:relabel:list", "platform:lastleg:inventory:list", + "platform:lastleg:dispatch:list", "platform:lastleg:overseas-procurement:list", + "platform:lastleg:us-trucking:list", + "INSERT IGNORE INTO iam_role_permission", "role.role_code = 'PLATFORM_ADMIN'"); + assertThat(sql).doesNotContain("(5106000, 0, 'PLATFORM'", "platform:lastleg:order:view"); + } + + @Test + void listPermissionsOwnDetailsAndRetiredDetailPermissionIsDeleted() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains( + "platform:lastleg:order:detail", "DELETE FROM iam_permission", + "(5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list'", + "(5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list'"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java index 3b63ff22f..7cc6f7ea0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -3,12 +3,14 @@ package com.baiyue.lastleg; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 */ @MapperScan("com.baiyue.lastleg.mapper") @SpringBootApplication(scanBasePackages = "com.baiyue") +@EnableScheduling public class BaiyueLastlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } } 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 5543eae2e..0f52f0900 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 @@ -19,6 +19,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,6 +50,7 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders") + @PreAuthorize("hasAuthority('platform:lastleg:order:list')") /** 分页查询尾程订单列表。 */ @Operation(summary = "分页查询尾程订单") public ApiResponse> list( @@ -59,6 +61,7 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @PreAuthorize("hasAuthority('platform:lastleg:order:list')") /** 查询指定入仓单维度的尾程订单详情。 */ @Operation(summary = "查询尾程订单详情") public ApiResponse detail( @@ -69,6 +72,7 @@ public class LastlegOrderController { } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:order:export')") /** 按筛选条件导出尾程订单 CSV 文件。 */ @Operation(summary = "导出尾程订单") public ResponseEntity export( @@ -83,6 +87,7 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @PreAuthorize("hasAuthority('platform:lastleg:order:transfer')") /** 将单个尾程订单转移到目标仓库。 */ @Operation(summary = "单个订单转移仓库") public ApiResponse transferOne( @@ -94,6 +99,7 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/destination") + @PreAuthorize("hasAuthority('platform:lastleg:order:transfer')") /** 将多个尾程订单批量转移到目标仓库。 */ @Operation(summary = "批量订单转移仓库") public ApiResponse transferBatch( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index dad601018..378b6adb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -38,6 +39,7 @@ public class DispatchController { @Resource private DispatchPdaWorkflowService pdaWorkflow; @GetMapping("/candidates") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 查询可用于智能预派车的未锁定托盘。 */ @Operation(summary = "查询可派车托盘") public ApiResponse> candidates( @@ -50,6 +52,7 @@ public class DispatchController { } @PostMapping("/pre-arrange") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:create')") /** 创建待出发派车单并锁定选中的托盘。 */ @Operation(summary = "创建预派车单") public ApiResponse preArrange( @@ -62,6 +65,7 @@ public class DispatchController { } @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 分页查询派车单。 */ @Operation(summary = "分页查询派车单") public ApiResponse> page( @@ -72,6 +76,7 @@ public class DispatchController { } @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 查询派车单详情及托盘目的地。 */ @Operation(summary = "查询派车单详情") public ApiResponse detail( @@ -82,6 +87,7 @@ public class DispatchController { } @PutMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:edit')") /** 编辑车辆、司机、预约、路线和出发信息。 */ @Operation(summary = "编辑派车单") public ApiResponse update( @@ -93,6 +99,7 @@ public class DispatchController { } @DeleteMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:delete')") /** 取消待处理派车单并释放托盘锁定。 */ @Operation(summary = "取消派车单") public ApiResponse cancel( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java index a1179f95d..109ec9c40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -24,5 +24,7 @@ public class LastlegEventOutbox { private LocalDateTime nextRetryAt; private LocalDateTime publishedAt; private String lastError; + private String lockedBy; + private LocalDateTime lockedAt; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 903ab3afb..4337ed4e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -56,7 +56,6 @@ public class LastlegOrder { private String warehouseRemark; private String sourceEventId; private String sourceLoadingPlanId; - private String sourceContainerBookingId; private Long sourceLoadingPlanVersion; private Long sourceHandoverVersion; private String sourcePayloadHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index c5ac307ac..949799eb2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -15,6 +15,7 @@ import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -26,6 +27,7 @@ public class InboundPlanController { @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')") /** 将入仓单转换为入仓计划。 */ @Operation(summary = "入仓单转入仓计划") public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @@ -34,6 +36,7 @@ public class InboundPlanController { } @GetMapping("/{planNo}") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')") /** 查询入仓计划及其明细、托盘和分配信息。 */ @Operation(summary = "查询入仓计划详情") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @@ -43,6 +46,7 @@ public class InboundPlanController { } @PostMapping("/{planNo}/pallets/supplement") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:edit')") /** 为计划目标仓新增一个可打印的补充托盘。 */ @Operation(summary = "新增补充托盘") public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, @@ -52,6 +56,7 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')") /** 为选中的入仓明细创建 FBA 预约。 */ @Operation(summary = "创建 FBA 预约") public ApiResponse batchFbaAppointment( @@ -63,6 +68,7 @@ public class InboundPlanController { } @GetMapping("/fba-appointments/{appointmentNo}") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')") /** 查询 FBA 预约及其选中的入仓明细。 */ @Operation(summary = "查询 FBA 预约详情") public ApiResponse fbaAppointmentDetail( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 9cc116ea1..566c38e07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -114,6 +117,15 @@ public class InboundPdaController { return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); } + @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadUnloadPhotoFile( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) { + requireOperate(user); + return ApiResponse.success(service.uploadUnloadPhotoFile( + scope(user, customerId), planNo, user.userId(), file)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 0c6a2bb40..fa261c613 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -12,6 +12,8 @@ public final class PdaResponses { List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} + public record Operation(String result, long allocationId, int requiredPackageCount, int countedPackageCount, int pickedPackageCount, BigDecimal actualVolumeCbm, Integer remainingPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java new file mode 100644 index 000000000..debcb721b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Component +public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read unload photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Unload photo upload failed"); + }).body(MetadataEnvelope.class); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { + throw new IllegalArgumentException("Unload photo metadata does not match the PDA task"); + } + } + + private long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + } + + private record MetadataEnvelope(Metadata data) {} + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java new file mode 100644 index 000000000..b1abf3e64 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface UnloadPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index d77ec080a..4c8b09a2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; @@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -40,12 +42,14 @@ import java.util.Objects; @Service public class InboundPdaWorkflowService { + private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024; private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -421,6 +425,7 @@ public class InboundPdaWorkflowService { InboundPdaTask task = requireActiveTask(customerId, plan); List fileIds = request.fileIds().stream().distinct().sorted().toList(); if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId)); LocalDateTime uploadedAt = LocalDateTime.now(); task.setUnloadPhotoFileIds(write(fileIds)); task.setUnloadPhotoUploadedAt(uploadedAt); @@ -433,6 +438,21 @@ public class InboundPdaWorkflowService { return result; } + public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo, + long operatorId, MultipartFile file) { + validateUnloadPhotoFile(file); + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = requireActiveTask(customerId, plan); + final UnloadPhotoFileGateway.UploadedFile uploaded; + try { + uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception); + } + return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, int packageCount, BigDecimal movedVolume) { source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); @@ -623,6 +643,27 @@ public class InboundPdaWorkflowService { } } + private void validateUnloadPhoto(String customerId, long taskId, long fileId) { + try { + unloadPhotoFiles.validate(customerId, taskId, fileId); + } catch (IllegalArgumentException exception) { + throw conflict("Unload photo does not exist or does not belong to this PDA task"); + } + } + + private static void validateUnloadPhotoFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty"); + } + if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB"); + } + String contentType = file.getContentType(); + if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG"); + } + } + private BigDecimal perPackageVolume(InboundPlanLine line) { int required = value(line.getRequiredPackageCount()); if (required <= 0) throw conflict("Inbound line required package count must be positive"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index a4a95a21c..6ec1c4f2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -9,11 +9,9 @@ import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,26 +27,21 @@ import java.util.List; @Service public class InboundUnloadCompletedOutboxService { @Resource private LastlegEventOutboxMapper mapper; - @Resource private LastlegOrderMapper orders; @Resource private ObjectMapper objectMapper; public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { - LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); - if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null - || sourceOrder.getSourceContainerBookingId().isBlank()) { - throw new IllegalStateException("Inbound plan has no source container booking ID"); - } Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); - var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt, + Instant unloadingCompletedAt = task.getCompletedAt().toInstant(ZoneOffset.UTC); + var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), unloadingCompletedAt, photoUploadedAt, null); JsonNode payloadNode = objectMapper.valueToTree(payload); - // Multiple inbound plans can share one container; upload epoch keeps their container events ordered. - long aggregateVersion = photoUploadedAt.toEpochMilli(); + // Multiple inbound plans can share one container; completion epoch keeps their container events ordered. + long aggregateVersion = unloadingCompletedAt.toEpochMilli(); String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", - sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + plan.getContainerNo(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 4ae6bc836..ba98083a6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -11,6 +11,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,11 +22,12 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/inventory") @Tag(name = "尾程入仓单库存操作") public class InventoryCommandController { - public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; - public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; + public static final String MANAGE_PERMISSION = "platform:lastleg:inventory:manage"; + public static final String ADJUST_PERMISSION = "platform:lastleg:inventory:adjust"; @Resource private InventoryCommandService service; @PostMapping("/hold") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 将可用入仓单箱数转为冻结。 */ @Operation(summary = "冻结入仓单库存") public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @@ -35,6 +37,7 @@ public class InventoryCommandController { } @PostMapping("/release") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 释放已冻结的入仓单箱数。 */ @Operation(summary = "释放入仓单库存") public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @@ -44,6 +47,7 @@ public class InventoryCommandController { } @PostMapping("/allocate") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 将可用入仓单箱数分配给出库单。 */ @Operation(summary = "分配出库库存") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @@ -53,6 +57,7 @@ public class InventoryCommandController { } @PostMapping("/unallocate") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 取消出库库存分配。 */ @Operation(summary = "取消出库分配") public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, @@ -62,6 +67,7 @@ public class InventoryCommandController { } @PostMapping("/pick") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 确认入仓单箱数已拣货。 */ @Operation(summary = "确认拣货") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @@ -71,6 +77,7 @@ public class InventoryCommandController { } @PostMapping("/ship") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 出库已拣货箱数并返回托盘是否为空。 */ @Operation(summary = "出库拣货库存") public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, @@ -80,6 +87,7 @@ public class InventoryCommandController { } @PostMapping("/adjust") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:adjust')") /** 根据实物盘点结果调整入仓单库存。 */ @Operation(summary = "盘点调整库存") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 3b5bc49e9..d683bfb04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @@ -35,6 +36,7 @@ public class InventoryQueryController { @Resource private InventoryQueryService service; @GetMapping(BASE_PATH + "/summary") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 查询入仓单库存汇总。 */ @Operation(summary = "查询库存汇总") public ApiResponse summary( @@ -45,6 +47,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 按客户、仓库和入仓单分页查询库存。 */ @Operation(summary = "分页查询库存余额") public ApiResponse> balances( @@ -55,6 +58,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 查询库存余额对应的托盘和批次位置。 */ @Operation(summary = "查询库存位置") public ApiResponse> positions( @@ -66,6 +70,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/ledger") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 分页查询只增不改的库存流水。 */ @Operation(summary = "分页查询库存流水") public ApiResponse> ledgers( @@ -76,6 +81,7 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:export')") /** 导出筛选后的入仓单库存 CSV 文件。 */ @Operation(summary = "导出库存余额") public ResponseEntity exportBalances( @@ -86,6 +92,7 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:export')") /** 导出筛选后的库存流水 CSV 文件。 */ @Operation(summary = "导出库存流水") public ResponseEntity exportLedgers( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java index c7871d177..4af644f86 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -3,7 +3,47 @@ package com.baiyue.lastleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventOutbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; @Mapper public interface LastlegEventOutboxMapper extends BaseMapper { + @Select(""" + SELECT id FROM ll_event_outbox + WHERE event_type='OverseasContainerUnloaded' AND retry_count < #{maxAttempts} + AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore})) + AND (next_retry_at IS NULL OR next_retry_at <= #{now}) + ORDER BY created_at,id LIMIT #{limit} + """) + List relayCandidates(@Param("limit") int limit, @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now, @Param("staleBefore") LocalDateTime staleBefore); + + @Update(""" + UPDATE ll_event_outbox SET status='PROCESSING',locked_by=#{worker},locked_at=#{now} + WHERE id=#{id} AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore})) + """) + int claim(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now, + @Param("staleBefore") LocalDateTime staleBefore); + + @Select("SELECT * FROM ll_event_outbox WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}") + LastlegEventOutbox claimed(@Param("id") long id, @Param("worker") String worker); + + @Update(""" + UPDATE ll_event_outbox SET status='PUBLISHED',published_at=#{now},next_retry_at=NULL, + last_error=NULL,locked_by=NULL,locked_at=NULL + WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker} + """) + int markPublished(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now); + + @Update(""" + UPDATE ll_event_outbox SET status=#{status},retry_count=retry_count+1,next_retry_at=#{retryAt}, + last_error=#{error},locked_by=NULL,locked_at=NULL + WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker} + """) + int markFailed(@Param("id") long id, @Param("worker") String worker, @Param("status") String status, + @Param("retryAt") LocalDateTime retryAt, @Param("error") String error); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index 2d0e71a37..0feef3409 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +27,7 @@ public class PlatformOverseasProcurementController { @Resource private OverseasProcurementService service; @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -35,6 +37,7 @@ public class PlatformOverseasProcurementController { } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { @@ -43,6 +46,7 @@ public class PlatformOverseasProcurementController { } @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:review')") public ApiResponse review( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { @@ -51,6 +55,7 @@ public class PlatformOverseasProcurementController { } @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { @@ -59,10 +64,11 @@ public class PlatformOverseasProcurementController { } @PostMapping("/{orderNo}/outbounds") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:outbound')") public ApiResponse addOutbound( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) { - requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + requirePermission(user, "platform:lastleg:overseas-procurement:outbound"); return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request)); } 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 d955c88d8..c9353182a 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 @@ -31,8 +31,8 @@ import java.util.Set; @Service public class OverseasProcurementService { - public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; - public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; + public static final String REVIEW_PERMISSION = "platform:lastleg:overseas-procurement:review"; + public static final String MANAGE_PERMISSION = "platform:lastleg:overseas-procurement:edit"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 349c89f69..de7f70612 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,21 +20,25 @@ public class RelabelApplicationController { @Resource private RelabelApplicationService service; @PostMapping + @PreAuthorize("hasAuthority('platform:lastleg:relabel:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:relabel:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('platform:lastleg:relabel:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); } @PutMapping("/{relabelNo}/status") + @PreAuthorize("hasAuthority('platform:lastleg:relabel:status')") public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.ChangeStatus request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java new file mode 100644 index 000000000..bd90f94c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +@Slf4j +public class ContainerUnloadOutboxRelay { + private static final int MAX_ATTEMPTS = 12; + @Resource private LastlegEventOutboxMapper outbox; + @Resource private EventBridgePublisher publisher; + private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.container-unload-relay.fixed-delay:3000}") + public void relay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime staleBefore = now.minusMinutes(2); + for (Long id : outbox.relayCandidates(50, MAX_ATTEMPTS, now, staleBefore)) { + relayOne(id, now, staleBefore); + } + } + + void relayOne(long id, LocalDateTime now, LocalDateTime staleBefore) { + if (outbox.claim(id, worker, now, staleBefore) != 1) return; + LastlegEventOutbox row = outbox.claimed(id, worker); + if (row == null) return; + try { + publisher.publish(row.getPayloadJson()); + outbox.markPublished(id, worker, LocalDateTime.now()); + } catch (Exception exception) { + int attempt = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; + boolean exhausted = attempt >= MAX_ATTEMPTS; + LocalDateTime retryAt = exhausted ? null : LocalDateTime.now().plusSeconds(backoffSeconds(attempt)); + outbox.markFailed(id, worker, exhausted ? "FAILED" : "PENDING", retryAt, + abbreviate(exception.getMessage())); + log.warn("海外卸柜事件投递失败 id={}, attempt={}, reason={}", id, attempt, exception.getMessage()); + } + } + + private static long backoffSeconds(int attempt) { + return Math.min(900L, 5L << Math.min(Math.max(attempt - 1, 0), 8)); + } + + private static String abbreviate(String value) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= 1000 ? safe : safe.substring(0, 1000); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java new file mode 100644 index 000000000..95dd15ba3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +@Component +public class EventBridgePublisher { + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.clients.event-bridge.url:http://127.0.0.1:9312}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + @PostConstruct + void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + } + + public void publish(String envelopeJson) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/internal/event-bridge/events")) + .timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(envelopeJson)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) { + throw new IllegalStateException("事件桥接收失败: HTTP " + response.statusCode()); + } + } +} 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 cf841602a..3d076a2b3 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 @@ -120,8 +120,7 @@ public class LoadingCompletedConsumer { order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); - order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); - order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); try { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index 3dba2f252..d156e77b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +27,7 @@ public class PlatformUsTruckOrderController { @Resource private UsTruckOrderService service; @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.Query query) { requirePlatform(user); @@ -33,6 +35,7 @@ public class PlatformUsTruckOrderController { } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); @@ -40,6 +43,7 @@ public class PlatformUsTruckOrderController { } @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:status')") public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { @@ -48,10 +52,11 @@ public class PlatformUsTruckOrderController { } @PostMapping("/{orderNo}/receive-pallets") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:receive')") public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { - requirePermission(user); + requirePermission(user, "platform:lastleg:us-trucking:receive"); return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } @@ -63,10 +68,14 @@ public class PlatformUsTruckOrderController { } private void requirePermission(AuthenticatedUser user) { + requirePermission(user, UsTruckOrderService.MANAGE_PERMISSION); + } + + private void requirePermission(AuthenticatedUser user, String permission) { requirePlatform(user); - if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { + if (user.permissions() == null || !user.permissions().contains(permission)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, - UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + permission + " permission is required"); } } 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 4c6453f2d..72eae0ca2 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 @@ -35,7 +35,7 @@ import java.util.function.Consumer; @Service public class UsTruckOrderService { - public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + public static final String MANAGE_PERMISSION = "platform:lastleg:us-trucking:status"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; /** Kept for source compatibility; new orders use PENDING_REVIEW. */ @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 250cdcb8d..907738ba3 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -35,6 +35,9 @@ spring: management.endpoints.web.exposure.include: health,info,prometheus baiyue: + clients: + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:http://127.0.0.1:9312} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8e3682ab8..049591454 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -62,7 +62,6 @@ CREATE TABLE IF NOT EXISTS ll_order ( warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', - source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', source_handover_version BIGINT COMMENT '交接版本', source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -164,8 +163,6 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history ( -- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; ALTER TABLE ll_order COMMENT='尾程订单主表'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); -PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -240,7 +237,6 @@ ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', - MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -519,7 +515,8 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox ( event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', - last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者', + locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java new file mode 100644 index 000000000..4fe041548 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.inboundplan.controller.InboundPlanController; +import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController; +import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; +import com.baiyue.lastleg.procurement.controller.PlatformOverseasProcurementController; +import com.baiyue.lastleg.relabel.controller.RelabelApplicationController; +import com.baiyue.lastleg.trucking.controller.PlatformUsTruckOrderController; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegPlatformPermissionContractTest { + @Test + void readEndpointsReuseListPermissionsAndActionsStayIndependent() { + assertPermission(LastlegOrderController.class, "list", "platform:lastleg:order:list"); + assertPermission(LastlegOrderController.class, "detail", "platform:lastleg:order:list"); + assertPermission(LastlegOrderController.class, "export", "platform:lastleg:order:export"); + assertPermission(LastlegOrderController.class, "transferOne", "platform:lastleg:order:transfer"); + + assertPermission(InboundPlanController.class, "detail", "platform:lastleg:inbound-plan:list"); + assertPermission(InboundPlanController.class, "fbaAppointmentDetail", "platform:lastleg:inbound-plan:list"); + assertPermission(InboundPlanController.class, "convert", "platform:lastleg:inbound-plan:create"); + assertPermission(InboundPlanController.class, "batchFbaAppointment", "platform:lastleg:inbound-plan:appointment"); + + assertPermission(RelabelApplicationController.class, "page", "platform:lastleg:relabel:list"); + assertPermission(RelabelApplicationController.class, "detail", "platform:lastleg:relabel:list"); + assertPermission(InventoryQueryController.class, "balances", "platform:lastleg:inventory:list"); + assertPermission(InventoryQueryController.class, "positions", "platform:lastleg:inventory:list"); + assertPermission(InventoryQueryController.class, "exportBalances", "platform:lastleg:inventory:export"); + assertPermission(InventoryCommandController.class, "hold", "platform:lastleg:inventory:manage"); + assertPermission(InventoryCommandController.class, "adjust", "platform:lastleg:inventory:adjust"); + + assertPermission(DispatchController.class, "page", "platform:lastleg:dispatch:list"); + assertPermission(DispatchController.class, "detail", "platform:lastleg:dispatch:list"); + assertPermission(DispatchController.class, "preArrange", "platform:lastleg:dispatch:create"); + + assertPermission(PlatformOverseasProcurementController.class, "page", "platform:lastleg:overseas-procurement:list"); + assertPermission(PlatformOverseasProcurementController.class, "detail", "platform:lastleg:overseas-procurement:list"); + assertPermission(PlatformUsTruckOrderController.class, "page", "platform:lastleg:us-trucking:list"); + assertPermission(PlatformUsTruckOrderController.class, "detail", "platform:lastleg:us-trucking:list"); + } + + private static void assertPermission(Class type, String methodName, String permission) { + Method method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 6caff0876..e1353af91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +21,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.mock.web.MockMultipartFile; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest { @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Mock UnloadPhotoFileGateway unloadPhotoFiles; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); + ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest { target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); - when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan); } @Test @@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest { assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); + verify(unloadPhotoFiles).validate("100", task.getId(), 101L); + verify(unloadPhotoFiles).validate("100", task.getId(), 103L); verify(repository).update(task); ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(operation.capture()); @@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).update(task); } + @Test + void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() { + activeTask(); + org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch")) + .when(unloadPhotoFiles).validate("100", task.getId(), 101L); + + assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1", + new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker"))); + + verify(repository, never()).update(task); + } + + @Test + void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() { + activeTask(); + var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3}); + when(unloadPhotoFiles.upload("100", task.getId(), 7L, file)) + .thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L)); + + var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file); + + assertEquals(101L, result.fileId()); + assertEquals("unload.jpg", result.fileName()); + assertEquals("image/jpeg", result.contentType()); + assertEquals(3L, result.sizeBytes()); + verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file); + } + + @Test + void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() { + var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]); + + assertThrows(ResponseStatusException.class, + () -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file)); + + verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any()); + } + + @Test + void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() { + var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1}); + + assertThrows(ResponseStatusException.class, + () -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file)); + + verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any()); + } + @Test void completesWhenMergedAllocationsPreserveExactLineTotals() { activeTask(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java index 3720afd97..53c6b0d6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -2,13 +2,12 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,7 +28,6 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxServiceTest { @Mock LastlegEventOutboxMapper outbox; - @Mock LastlegOrderMapper orders; private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private InboundUnloadCompletedOutboxService service; @@ -37,17 +35,15 @@ class InboundUnloadCompletedOutboxServiceTest { void setUp() { service = new InboundUnloadCompletedOutboxService(); ReflectionTestUtils.setField(service, "mapper", outbox); - ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); } @Test - void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception { - InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1"); + void writesAValidOverseasToCnEnvelopeUsingTheContainerNo() throws Exception { + InboundPlan plan = new InboundPlan(); plan.setContainerNo("MSCU1"); InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); - task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3); - LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001"); - when(orders.selectById(11L)).thenReturn(order); + task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); + task.setCompletedAt(LocalDateTime.of(2026, 8, 20, 10, 5)); task.setVersion(3); service.publish(plan, task, List.of(101L)); @@ -57,15 +53,10 @@ class InboundUnloadCompletedOutboxServiceTest { assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType()); assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion()); assertEquals(RegionCode.CN, envelope.targetRegion()); - assertEquals("9001", envelope.aggregateId()); + assertEquals("MSCU1", envelope.aggregateId()); assertEquals("MSCU1", envelope.payload().path("containerNo").asText()); + OverseasContainerUnloadedV1 payload = objectMapper.treeToValue(envelope.payload(), OverseasContainerUnloadedV1.class); + assertEquals("2026-08-20T10:05:00Z", payload.unloadingCompletedAt().toString()); } - @Test - void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() { - InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); - InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); - when(orders.selectById(11L)).thenReturn(new LastlegOrder()); - assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L))); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java new file mode 100644 index 000000000..03f6cff6b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ContainerUnloadOutboxRelayTest { + private final LastlegEventOutboxMapper outbox = mock(LastlegEventOutboxMapper.class); + private final EventBridgePublisher publisher = mock(EventBridgePublisher.class); + private ContainerUnloadOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new ContainerUnloadOutboxRelay(); + ReflectionTestUtils.setField(relay, "outbox", outbox); + ReflectionTestUtils.setField(relay, "publisher", publisher); + } + + @Test + void publishesClaimedEnvelopeAndMarksItDone() throws Exception { + LastlegEventOutbox row = row(0); + when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1); + when(outbox.claimed(eq(11L), anyString())).thenReturn(row); + + relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2)); + + verify(publisher).publish("{\"eventId\":\"event-1\"}"); + verify(outbox).markPublished(eq(11L), anyString(), any()); + verify(outbox, never()).markFailed(anyLong(), anyString(), anyString(), any(), anyString()); + } + + @Test + void schedulesRetryWhenBridgeIsUnavailable() throws Exception { + LastlegEventOutbox row = row(2); + when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1); + when(outbox.claimed(eq(11L), anyString())).thenReturn(row); + doThrow(new IllegalStateException("bridge down")).when(publisher).publish(anyString()); + + relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2)); + + verify(outbox).markFailed(eq(11L), anyString(), eq("PENDING"), any(), eq("bridge down")); + verify(outbox, never()).markPublished(anyLong(), anyString(), any()); + } + + private LastlegEventOutbox row(int retries) { + LastlegEventOutbox value = new LastlegEventOutbox(); + value.setId(11L); value.setPayloadJson("{\"eventId\":\"event-1\"}"); value.setRetryCount(retries); + return value; + } +} 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 cab178bdb..4f3142a49 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 @@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); 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)); } - @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(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->"book-1".equals(o.getSourceContainerBookingId()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 298df8d32..efd95eb35 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3577,3 +3577,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; + +-- ============================================================================ +-- 2026-08-20 / 028 +-- Overseas PDA unload photos and container empty-return event outbox. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者', + locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_outbox'), 'ALTER TABLE ll_event_outbox COMMENT=''尾程跨区业务事件发件箱''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN id BIGINT NOT NULL COMMENT ''事件主键ID''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='customer_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT ''聚合类型''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT ''聚合标识''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_version'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_version INT NOT NULL COMMENT ''事件版本''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_key'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='payload_json'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT ''事件载荷JSON''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='status'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''发布状态''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='retry_count'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''重试次数''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='next_retry_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次重试时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='published_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN published_at DATETIME(3) NULL COMMENT ''发布时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='last_error'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后一次发布错误''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='created_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +-- ============================================================================ +-- 2026-08-20 / 029 +-- Reliable lease for the overseas container-unload event relay. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''当前投递租约持有者'' AFTER last_error')); +PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''当前投递租约获取时间'' AFTER locked_by')); +PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index e8bcfb10f..9ae72fe0f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -53,6 +54,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PostMapping + @PreAuthorize("hasAuthority('platform:warehouse:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); @@ -72,6 +74,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -95,6 +98,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") }) @GetMapping + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -115,6 +119,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -137,6 +142,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:warehouse:status')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7e714246b..fca6be895 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -14,6 +14,7 @@ import jakarta.validation.Valid; import jakarta.annotation.Resource; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -32,6 +33,7 @@ public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; @PostMapping("/locations") + @PreAuthorize("hasAuthority('platform:location:create')") public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -41,6 +43,7 @@ public class PlatformWarehouseResourceController { } @PutMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:edit')") public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @@ -51,6 +54,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/locations") + @PreAuthorize("hasAuthority('platform:location:list')") public ApiResponse> locations( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { @@ -59,6 +63,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:list')") public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -68,6 +73,7 @@ public class PlatformWarehouseResourceController { } @DeleteMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:delete')") public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { @@ -77,6 +83,7 @@ public class PlatformWarehouseResourceController { } @PostMapping("/pallets") + @PreAuthorize("hasAuthority('platform:pallet:create')") public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -86,6 +93,7 @@ public class PlatformWarehouseResourceController { } @PutMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:edit')") public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @@ -96,6 +104,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/pallets") + @PreAuthorize("hasAuthority('platform:pallet:list')") public ApiResponse> pallets( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { @@ -104,6 +113,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:list')") public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { @@ -112,6 +122,7 @@ public class PlatformWarehouseResourceController { } @DeleteMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:delete')") public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java new file mode 100644 index 000000000..61bc96c5a --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformWarehousePermissionContractTest { + @Test + void platformWarehouseReadsUseListAndWritesUseIndependentPermissions() { + assertPermission(PlatformWarehouseController.class, "page", "platform:warehouse:list"); + assertPermission(PlatformWarehouseController.class, "detail", "platform:warehouse:list"); + assertPermission(PlatformWarehouseController.class, "create", "platform:warehouse:create"); + assertPermission(PlatformWarehouseController.class, "update", "platform:warehouse:edit"); + assertPermission(PlatformWarehouseController.class, "status", "platform:warehouse:status"); + + assertPermission(PlatformWarehouseResourceController.class, "locations", "platform:location:list"); + assertPermission(PlatformWarehouseResourceController.class, "location", "platform:location:list"); + assertPermission(PlatformWarehouseResourceController.class, "createLocation", "platform:location:create"); + assertPermission(PlatformWarehouseResourceController.class, "pallets", "platform:pallet:list"); + assertPermission(PlatformWarehouseResourceController.class, "pallet", "platform:pallet:list"); + assertPermission(PlatformWarehouseResourceController.class, "createPallet", "platform:pallet:create"); + } + + private static void assertPermission(Class type, String methodName, String permission) { + Method method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +} From 1f0c0f77510d44b7e1ce7b012c448e680785e2a1 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:59:45 +0800 Subject: [PATCH 019/231] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 1d5999c8b..0d7f5885a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -69,7 +69,7 @@ - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 - 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 -- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 +- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;平台后台库存表单操作要求 `platform:lastleg:inventory:manage`,盘点调整另要求 `platform:lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 ## 修改与验证 From c9a05b108bf834d1cccec0d0f4b7161733224d18 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 16:45:47 +0800 Subject: [PATCH 020/231] =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/FirstLegLoadingPlanCompletedV2.java | 6 ++-- .../FirstLegLoadingPlanCompletedV2Test.java | 15 ++++++++ .../LoadingCompletedOutboxService.java | 10 ++++++ .../LoadingCompletedOutboxServiceTest.java | 12 +++++++ .../service/LoadingCompletedConsumer.java | 36 +++++++++++++++++++ .../service/LoadingCompletedConsumerTest.java | 27 +++++++++++--- 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java index 097bf69e0..07e473d43 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java @@ -46,7 +46,9 @@ public record FirstLegLoadingPlanCompletedV2( String destinationWarehouseName, String fbaTrackingNo, String shipmentNo, String shippingMark, String customerRemark, String internalRemark, String warehouseRemark, - String palletNo, List cartonMarks) { + String palletNo, + /** 头程箱唛二维码中的箱号;这些箱号均对应本明细的 skuCode。 */ + List cartonMarks) { public Item { cartonMarks = cartonMarks == null ? List.of() : cartonMarks; } @@ -72,7 +74,7 @@ public record FirstLegLoadingPlanCompletedV2( /** 一键代发入仓单的托盘冻结快照。 */ public record PalletSnapshot(String palletNo, List skuPackages) {} - /** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */ + /** 托盘下 SKU、箱数、数量及箱号冻结快照;cartonMarks 中每个箱号都对应 skuCode。 */ public record SkuPackageSnapshot(String skuCode, String productName, int packageCount, int productQuantity, List cartonMarks) {} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java index 0856e4da7..b5bb212f4 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2Test.java @@ -39,4 +39,19 @@ class FirstLegLoadingPlanCompletedV2Test { assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP"); assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2); } + + @Test + void mapsEachOrdinaryCartonNumberToItsSku() { + var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU-PSC2", "产品", "Product", 2, + 20, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02")); + var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK", + null, null, null, 2, 20, null, null, null, null, List.of(item)); + + var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2( + "4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order))); + + assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2"); + assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index 922cd7cca..c0abb9c50 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -109,9 +109,19 @@ public class LoadingCompletedOutboxService { Map> result = new java.util.LinkedHashMap<>(); for (Row row : rows) { if (dropship(row.getBusinessType())) continue; + if (row.getSkuCode() == null || row.getSkuCode().isBlank()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系"); + } List labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(), row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of()); if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件"); + if (labels.stream().anyMatch(label -> label == null || label.isBlank()) + || labels.stream().distinct().count() != labels.size()) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号"); + } + if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) { + throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件"); + } result.put(row.getLoadingPlanItemId(), labels); } return result; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index d7c085e81..46bf81b01 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -19,6 +19,7 @@ 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.mock; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; @@ -52,10 +53,21 @@ class LoadingCompletedOutboxServiceTest { assertThat(first.payload().get("orders")).hasSize(2); assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); + assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101"); + assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1"); assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2"); assertThat(first.eventVersion()).isEqualTo(2); } + @Test void rejectsOrdinaryItemWhenSkuToCartonMappingCannotBeBuilt() { + Row value = row(11, 100, 101); + value.setSkuCode(null); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("缺少 SKU"); + } + @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); 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 3d076a2b3..64c2ce113 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 @@ -19,7 +19,9 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Objects; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.fasterxml.jackson.databind.JsonNode; @Service @@ -48,6 +50,7 @@ public class LoadingCompletedConsumer { if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); if(payload.orders()==null||payload.orders().isEmpty()) throw new IllegalArgumentException("事件没有入仓单"); + if (envelope.eventVersion() == 2) validateCartonSkuMappings(payload); LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); @@ -88,6 +91,39 @@ public class LoadingCompletedConsumer { return Math.toIntExact(orderMapper.countContainer(envelope.tenantId(),containerNo)); } + private void validateCartonSkuMappings(FirstLegLoadingPlanCompletedV1 payload) { + for (FirstLegLoadingPlanCompletedV1.Order order : payload.orders()) { + Set orderCartonMarks = new HashSet<>(); + if ("ONE_CLICK_DROPSHIP".equals(order.orderType())) { + for (FirstLegLoadingPlanCompletedV1.PalletSnapshot pallet : order.pallets()) { + for (FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot sku : pallet.skuPackages()) { + requireSkuCartonMapping(sku.skuCode(), sku.packageCount(), sku.cartonMarks(), orderCartonMarks); + } + } + continue; + } + for (FirstLegLoadingPlanCompletedV1.Item item : order.items()) { + requireSkuCartonMapping(item.skuCode(), item.packageCount(), item.cartonMarks(), orderCartonMarks); + } + } + } + + private void requireSkuCartonMapping(String skuCode, int loadedPackages, List cartonMarks, + Set orderCartonMarks) { + if (skuCode == null || skuCode.isBlank()) { + throw new IllegalArgumentException("V2 尾程交接事件的箱唛映射缺少 SKU"); + } + List marks = cartonMarks == null ? List.of() : cartonMarks; + if (marks.size() < loadedPackages) { + throw new IllegalArgumentException("V2 尾程交接事件的 SKU 箱唛数量少于实装箱数: " + skuCode); + } + for (String mark : marks) { + if (mark == null || mark.isBlank() || !orderCartonMarks.add(mark)) { + throw new IllegalArgumentException("V2 尾程交接事件包含空值或重复箱唛: " + skuCode); + } + } + } + private void createOrder(EventEnvelope event,FirstLegLoadingPlanCompletedV1 payload, FirstLegLoadingPlanCompletedV1.Order source,String orderNo, JsonNode rawSource) { String orderHash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(rawSource)); 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 4f3142a49..3ee2c40f6 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 @@ -1,24 +1,43 @@ 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.service.InboundPlanService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; 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 LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); 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)); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; 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,"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,"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)); } @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(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()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ - EventEnvelope source=event(); + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)).put("customerRemark","fragile"); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(0).path("items").get(0)) + .set("cartonMarks", json.valueToTree(List.of("BOX-1","BOX-2","BOX-3","BOX-4","BOX-5"))); + ((com.fasterxml.jackson.databind.node.ObjectNode) node.path("orders").get(1).path("items").get(0)) + .set("cartonMarks", json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(2,service.consume(v2)); verify(orders,times(2)).insert(any(LastlegOrder.class)); + ArgumentCaptor cartonCaptor= + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton.class); + verify(cartons,times(8)).insert(cartonCaptor.capture()); + assertEquals("SKU-1",cartonCaptor.getAllValues().get(0).getSkuCode()); + assertEquals("BOX-1",cartonCaptor.getAllValues().get(0).getCartonMark()); + } + @Test void rejectsV2EventWithoutSkuCartonMapping(){ + EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); + String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); + EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), + source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), + 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); + IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(v2)); + assertTrue(error.getMessage().contains("箱唛数量少于实装箱数")); + verify(orders,never()).insert(any(LastlegOrder.class)); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } } From 1cd8b6a4733e2a5a8bc8655c1229173adfc6391a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 18:02:38 +0800 Subject: [PATCH 021/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- Jenkinsfile | 12 +- Jenkinsfile.service | 7 +- .../auth/service/OverseasTokenService.java | 4 +- .../service/OverseasTokenServiceTest.java | 15 +- common-event-contract/AGENTS.md | 6 +- .../event/OverseasContainerUnloadedV1.java | 3 +- .../OverseasContainerUnloadedV1Test.java | 4 +- config/nacos/cn/event-bridge-service.yml | 2 + config/nacos/cn/firstleg-service.yml | 7 + .../nacos/overseas/event-bridge-service.yml | 2 +- config/nacos/overseas/lastleg-service.yml | 13 + deploy/docker-compose.overseas.yml | 97 ++++++ deploy/docker-compose.yml | 7 +- deploy/scripts/deploy-built-service.sh | 98 +++++- deploy/scripts/deploy-development-service.sh | 16 +- deploy/scripts/deploy-services.sh | 110 +++++- deploy/service-map.yml | 18 +- deploy/sql/README.md | 10 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 215 +++++++++--- deploy/sql/海外区域统一增量-MySQL8.sql | 254 ++++++++++++++ docs/头程项目目录结构与服务说明.md | 7 +- .../controller/EventBridgeController.java | 2 +- .../service/EventInboxService.java | 28 +- .../src/main/resources/application.yml | 4 +- .../service/EventInboxServiceTest.java | 43 ++- firstleg/AGENTS.md | 6 +- .../CarrierScheduleIntegrationClient.java | 28 ++ .../firstleg/dto/ContainerOverviewDtos.java | 8 + .../mapper/ContainerBookingMapper.java | 2 +- .../service/OutboundDispatchService.java | 17 +- ...nternalCarrierScheduleQueryController.java | 48 +++ ...latformCarrierScheduleQueryController.java | 17 +- .../domain/CarrierScheduleNode.java | 29 ++ .../dto/CarrierScheduleQueryDtos.java | 41 ++- .../entity/CarrierScheduleNodeFactEntity.java | 32 ++ .../entity/CarrierScheduleQueryEntity.java | 8 + .../mapper/CarrierScheduleNodeFactMapper.java | 23 ++ .../mapper/CarrierScheduleQueryMapper.java | 36 ++ .../CarrierScheduleQueryRepository.java | 19 ++ .../CarrierScheduleAdapterRegistry.java | 17 + .../CarrierScheduleQueryScheduler.java | 71 ++++ .../service/CarrierScheduleQueryService.java | 253 +++++++++++--- .../LoadingCompletedOutboxRepository.java | 20 +- .../service/ContainerOverviewService.java | 9 +- .../service/LoadingCompletedOutboxRelay.java | 20 +- .../OverseasContainerEventConsumer.java | 30 +- .../phase3-carrier-schedule-query-schema.sql | 76 ++++- .../db/phase3-ocean-booking-schema.sql | 30 +- .../db/phase3-truck-appointment-schema.sql | 8 +- .../openapi/carrier-schedule-internal-v1.yaml | 23 ++ .../openapi/carrier-schedule-query-v1.yaml | 32 +- ...ontainerOverviewTransportContractTest.java | 21 ++ .../dispatch/OutboundDispatchServiceTest.java | 21 ++ ...rrierScheduleQueryOpenApiContractTest.java | 12 +- ...erScheduleQueryControllerContractTest.java | 5 +- .../CarrierScheduleQuerySqlContractTest.java | 13 +- .../CarrierScheduleQuerySchedulerTest.java | 41 +++ .../CarrierScheduleQueryServiceTest.java | 58 ++-- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../LoadingCompletedOutboxRelayTest.java | 96 ++++++ .../OverseasContainerEventConsumerTest.java | 12 +- .../gateway/config/GatewaySecurityConfig.java | 1 - .../config/RequiredClaimsJwtValidator.java | 40 ++- .../main/resources/application-overseas.yml | 2 + .../RequiredClaimsJwtValidatorTest.java | 49 ++- .../main/resources/db/lastleg-permissions.sql | 71 +++- ...erseasPhase4PermissionSqlContractTest.java | 77 +++++ integration-service/AGENTS.md | 2 +- .../IntegrationProviderController.java | 16 + .../domain/CarrierScheduleStandardNode.java | 17 + .../integration/dto/CarrierScheduleDtos.java | 23 ++ .../CarrierScheduleProviderAdapter.java | 9 + ...mpliantCarrierScheduleProviderAdapter.java | 35 ++ .../CarrierScheduleProviderService.java | 39 +++ .../openapi/carrier-schedule-provider-v1.yaml | 55 +++ .../CarrierScheduleProviderServiceTest.java | 41 +++ lastleg-service/AGENTS.md | 23 +- lastleg-service/pom.xml | 3 +- .../lastleg/BaiyueLastlegApplication.java | 4 + .../lastleg/client/EventBridgeClient.java | 20 ++ .../controller/LastlegOrderController.java | 17 +- .../DeliveryTransferController.java | 2 + .../delivery/dto/DeliveryResponses.java | 9 +- .../service/DeliveryTransferService.java | 10 +- .../controller/DispatchController.java | 2 + .../pda/controller/DispatchPdaController.java | 4 +- .../dto/LastlegOrderDetailResponse.java | 22 +- .../lastleg/dto/LastlegOrderSummary.java | 8 +- .../lastleg/entity/LastlegEventOutbox.java | 2 + .../controller/InboundPlanController.java | 30 +- .../inboundplan/dto/InboundPlanRequests.java | 30 ++ .../inboundplan/dto/InboundPlanSummary.java | 32 ++ .../inboundplan/mapper/InboundPlanMapper.java | 37 +++ .../InboundPalletAssignmentController.java | 2 +- .../pda/controller/InboundPdaController.java | 2 +- .../pda/entity/InboundStockReceipt.java | 3 + .../service/InboundStockReceiptService.java | 90 ++++- .../InboundUnloadCompletedOutboxRelay.java | 54 +++ .../service/InboundPlanService.java | 16 + .../InventoryCommandController.java | 47 +-- .../command/dto/InventoryCommandRequests.java | 3 + .../service/InventoryCommandService.java | 14 +- .../inventory/entity/InventoryBalance.java | 3 + .../mapper/InventoryBalanceMapper.java | 10 +- .../controller/InventoryQueryController.java | 20 +- .../query/dto/InventoryBalanceView.java | 3 + .../query/dto/InventoryQueryRequests.java | 2 + .../query/mapper/InventoryQueryMapper.java | 4 + .../query/service/InventoryQueryService.java | 7 +- .../LooseboardMapperConfiguration.java | 9 + .../PlatformLocalLooseboardController.java | 97 ++++++ .../dto/LocalLooseboardRequests.java | 72 ++++ .../dto/LocalLooseboardResponses.java | 25 ++ .../entity/LocalLooseboardHistory.java} | 17 +- .../entity/LocalLooseboardOrder.java | 42 +++ .../mapper/LocalLooseboardHistoryMapper.java | 15 + .../mapper/LocalLooseboardOrderMapper.java | 19 ++ .../service/LocalLooseboardService.java | 302 +++++++++++++++++ ...PlatformMonthlyExchangeRateController.java | 58 ++++ .../exchange/dto/MonthlyExchangeRateDtos.java | 36 ++ .../exchange/entity/MonthlyExchangeRate.java | 26 ++ .../entity/MonthlyExchangeRateHistory.java | 23 ++ .../MonthlyExchangeRateHistoryMapper.java | 8 + .../mapper/MonthlyExchangeRateMapper.java | 19 ++ .../service/MonthlyExchangeRateService.java | 156 +++++++++ .../DomesticPartnerWarehouseClient.java | 18 + .../PlatformOverseasQuoteController.java | 107 ++++++ .../quote/dto/OverseasQuoteRequests.java | 60 ++++ .../quote/dto/OverseasQuoteResponses.java | 30 ++ .../quote/entity/OverseasQuote.java | 32 ++ .../quote/entity/OverseasQuoteFee.java | 21 ++ .../entity/OverseasQuoteTransitPrice.java | 22 ++ .../quote/mapper/OverseasQuoteFeeMapper.java | 8 + .../quote/mapper/OverseasQuoteMapper.java | 28 ++ .../OverseasQuoteTransitPriceMapper.java | 8 + .../quote/service/OverseasQuoteService.java | 227 +++++++++++++ .../service/PartnerWarehouseDirectory.java | 57 ++++ .../controller/PlatformVehicleController.java | 58 ++++ .../masterdata/vehicle/dto/VehicleDtos.java | 47 +++ .../masterdata/vehicle/entity/Vehicle.java | 30 ++ .../vehicle/mapper/VehicleMapper.java | 13 + .../vehicle/service/VehicleService.java | 143 ++++++++ ...PlatformOverseasProcurementController.java | 43 ++- .../dto/OverseasProcurementRequests.java | 29 +- .../dto/OverseasProcurementResponses.java | 14 +- .../entity/OverseasProcurementOrder.java | 1 - .../OverseasProcurementStatusHistory.java | 2 + .../OverseasProcurementOrderMapper.java | 13 +- .../OverseasProcurementOutboundMapper.java | 16 - .../service/OverseasProcurementService.java | 207 ++++++++---- .../RelabelApplicationController.java | 11 + .../UnloadCompletedOutboxRepository.java | 82 +++++ .../lastleg/security/OverseasPermissions.java | 46 +++ .../lastleg/service/LastlegOrderService.java | 50 +-- .../service/WarehouseTransferService.java | 5 + .../MerchantUsTruckOrderController.java | 2 +- .../PlatformUsTruckOrderController.java | 41 ++- .../trucking/service/UsTruckOrderService.java | 10 +- .../src/main/resources/application.yml | 12 +- .../src/main/resources/db/lastleg.sql | 134 +++++++- .../openapi/overseas-business-v1.yaml | 312 ++++++++++++++++++ .../openapi/overseas-master-data-v1.yaml | 273 +++++++++++++++ .../LastlegOrderControllerTest.java | 8 +- .../Phase4BrowserApiBoundaryTest.java | 61 ++++ .../service/DeliveryTransferServiceTest.java | 25 +- .../InboundPlanSchemaContractTest.java | 6 +- .../InboundPlanPermissionContractTest.java | 28 ++ .../InboundStockReceiptServiceTest.java | 8 +- ...InboundUnloadCompletedOutboxRelayTest.java | 98 ++++++ .../service/InboundPlanServiceTest.java | 32 ++ .../InventorySchemaContractTest.java | 10 +- .../command/InventoryCommandServiceTest.java | 10 +- .../InventoryQueryControllerTest.java | 2 +- .../service/InventoryQueryServiceTest.java | 5 +- .../LocalLooseboardSchemaContractTest.java | 21 ++ ...PlatformLocalLooseboardControllerTest.java | 60 ++++ .../service/LocalLooseboardServiceTest.java | 113 +++++++ ...OverseasMasterDataOpenApiContractTest.java | 24 ++ .../MonthlyExchangeRateSqlContractTest.java | 19 ++ ...hlyExchangeRateControllerContractTest.java | 29 ++ .../MonthlyExchangeRateServiceTest.java | 39 +++ ...rmOverseasQuoteControllerContractTest.java | 30 ++ .../PartnerWarehouseDirectoryTest.java | 56 ++++ .../vehicle/VehicleSqlContractTest.java | 21 ++ ...PlatformVehicleControllerContractTest.java | 28 ++ .../vehicle/service/VehicleServiceTest.java | 63 ++++ ...rseasProcurementInventoryBoundaryTest.java | 3 + ...verseasProcurementOpenApiContractTest.java | 42 +++ ...OverseasProcurementSchemaContractTest.java | 8 +- .../OverseasProcurementControllerTest.java | 9 + .../OverseasProcurementServiceTest.java | 83 ++++- .../SchemaChineseCommentContractTest.java | 40 ++- .../service/LastlegOrderServiceTest.java | 41 +-- .../service/WarehouseTransferServiceTest.java | 4 +- .../UsTruckOrderControllerTest.java | 19 ++ scripts/db/incremental.sql | 117 +++++++ scripts/finance-deployment-contract.test.mjs | 4 +- scripts/overseas-deployment-contract.test.mjs | 69 ++++ warehouse-overseas-service/AGENTS.md | 1 + .../PlatformWarehouseController.java | 20 +- .../PlatformWarehouseResourceController.java | 85 +++-- .../WarehouseResourceLockRepository.java | 24 +- .../WarehouseResourceRepository.java | 4 + .../OverseasWarehousePermissions.java | 19 ++ .../service/WarehouseResourceService.java | 10 +- .../src/main/resources/application.yml | 6 +- .../WarehouseResourceControllerTest.java | 19 +- ...rehouseResourcePermissionContractTest.java | 41 +++ .../service/WarehouseResourceServiceTest.java | 17 + 210 files changed, 6974 insertions(+), 825 deletions(-) create mode 100644 deploy/docker-compose.overseas.yml create mode 100644 deploy/sql/海外区域统一增量-MySQL8.sql create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java create mode 100644 firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java create mode 100644 integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml create mode 100644 integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/LooseboardMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java rename lastleg-service/src/main/java/com/baiyue/lastleg/{procurement/entity/OverseasProcurementOutbound.java => looseboard/entity/LocalLooseboardHistory.java} (54%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRateHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteFee.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java create mode 100644 lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java create mode 100644 scripts/overseas-deployment-contract.test.mjs create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java diff --git a/AGENTS.md b/AGENTS.md index e7699abfa..afc6b887d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ - 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 - 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。 -- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 +- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 - 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 ## 契约、数据库与验证入口 diff --git a/Jenkinsfile b/Jenkinsfile index 941569a2f..94ce52fb3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', + 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -14,6 +15,8 @@ def serviceCheckboxes = [ 'file': 'SERVICE_FILE', 'print': 'SERVICE_PRINT', 'integration-service': 'SERVICE_INTEGRATION', + 'lastleg-service': 'SERVICE_LASTLEG', + 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -47,6 +50,7 @@ pipeline { parameters { choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') + booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') @@ -56,7 +60,9 @@ pipeline { booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)') booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)') booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)') + booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') + booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') + booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由环境凭据 REGION_CODE 显式选择 CN 或 OVERSEAS 实例)') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 8216948b2..a84035a53 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,10 +1,11 @@ def serviceModules = [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'event-bridge-service', 'audit-service' + 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', + 'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', @@ -14,6 +15,8 @@ def serviceJars = [ 'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar', 'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar', 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', + 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', + 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] 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 2da6a5e9c..3da3caeb0 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -46,9 +46,9 @@ public class OverseasTokenService { .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId()) + .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) .claim("membership_id", authorization.getMembershipId()) - .claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType()) + .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", authorization.getPermissions()) .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) 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 49b8ee5b0..642adfe56 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -45,10 +45,11 @@ class OverseasTokenServiceTest { @Test void signsShortRs256TokenWithCrossRegionClaims() { AuthAccount account = new AuthAccount(); - account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L); + account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); - AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB", - Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L); + String authority = "platform:overseas-master-data:quote:list"; + AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", + Set.of("PLATFORM_ADMIN"), Set.of(authority), 7L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -64,12 +65,12 @@ class OverseasTokenServiceTest { assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(parameters.getClaims().getClaims()) - .containsEntry("user_id", 42L).containsEntry("tenant_id", 100L) + .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) .containsEntry("membership_id", 84L) - .containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB") + .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("MERCHANT_ADMIN")) - .containsEntry("authorities", Set.of("merchant:order:list")) + .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("authorities", Set.of(authority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); } diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index ec95783fb..6b92556e4 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,13 +14,15 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | -| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` | +| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java` | +| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`OverseasContainerUnloadedV1Test.java` | ## 修改与验证 - 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 - MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 +- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java index abf2ce607..6841c9815 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/OverseasContainerUnloadedV1.java @@ -2,8 +2,9 @@ package com.baiyue.common.event; import java.time.Instant; -/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */ +/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */ public record OverseasContainerUnloadedV1( + String sourceContainerBookingId, String containerNo, Instant unloadingCompletedAt, Instant unloadingPhotoUploadedAt, diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java index dc847949b..70f843856 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/OverseasContainerUnloadedV1Test.java @@ -13,12 +13,12 @@ class OverseasContainerUnloadedV1Test { @Test void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception { - var payload = new OverseasContainerUnloadedV1("MSCU1234567", + var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); String json = mapper.writeValueAsString(payload); assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload); - assertThat(json).contains("MSCU1234567", "unloadingPhotoUploadedAt"); + assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt"); } } diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index a5fbba087..baa031c2d 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,5 @@ baiyue: region: ${REGION_CODE:CN} deployment-cell: ${DEPLOYMENT_CELL:prod-cn} data-residency: ${DATA_RESIDENCY:CN} + event-dispatcher: + consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index dd326ed80..c620909f7 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -7,6 +7,13 @@ spring: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue: + lastleg: + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} + outbox-relay: + fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: inbound: allow-rejected-measurement: false diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 610138e21..27c492a4b 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -9,7 +9,7 @@ baiyue: deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: - consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 1b88f047a..5cdbb6d01 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -4,3 +4,16 @@ spring: username: ${LASTLEG_DB_USERNAME} password: ${LASTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + warehouse: + base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} + firstleg: + event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} + outbox-relay: + fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml new file mode 100644 index 000000000..c7cc50e2f --- /dev/null +++ b/deploy/docker-compose.overseas.yml @@ -0,0 +1,97 @@ +name: firstleg-${DEPLOY_ENV:-test}-overseas + +x-overseas-common: &overseas-common + restart: unless-stopped + networks: [firstleg-overseas-network, infra-network] + extra_hosts: + - "host.docker.internal:host-gateway" + environment: &overseas-environment + SPRING_PROFILES_ACTIVE: overseas + NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} + NACOS_USERNAME: ${NACOS_USERNAME} + NACOS_PASSWORD: ${NACOS_PASSWORD} + NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} + NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} + REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} + REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} + REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} + REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} + INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} + JWT_ISSUER: ${JWT_ISSUER} + JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} + JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} + REGION_CODE: OVERSEAS + DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} + DATA_RESIDENCY: OVERSEAS + SPRING_SQL_INIT_MODE: never + LOG_PATH: /app/logs + TZ: Asia/Shanghai + logging: + driver: json-file + options: + max-size: 100m + max-file: "5" + +services: + gateway-overseas: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas + ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9300 + SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} + CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} + CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} + + lastleg-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-lastleg + ports: ["${LASTLEG_PORT:-9303}:9303"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9303 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas + ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + + event-bridge-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas + ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9312 + EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} + EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} + EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} + EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + +networks: + firstleg-overseas-network: + name: firstleg-${DEPLOY_ENV:-test}-overseas-network + infra-network: + external: true + name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index aac92b985..60790fe27 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -86,6 +86,7 @@ services: FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} + LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} finance: <<: *common @@ -185,14 +186,16 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"] + ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment - SERVER_PORT: 9300 + SERVER_PORT: 9312 EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} + EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} + SPRING_SQL_INIT_MODE: never audit-service: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index e6eb3fa1e..420d2e9d7 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -30,12 +30,10 @@ case "$SERVICE" in ;; esac -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi -done +if [ ! -f "$ENV_FILE" ]; then + echo "必需文件不存在: $ENV_FILE" >&2 + exit 2 +fi env_file_has_key() { grep -q "^$1=" "$ENV_FILE" @@ -45,6 +43,37 @@ env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' } +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + event-bridge-service) + if ! env_file_has_key REGION_CODE; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$(env_file_value REGION_CODE)" in + CN) ;; + OVERSEAS) + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" + ;; + *) + echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 + exit 2 + ;; + esac + ;; +esac + +for file in "$ENV_FILE" "$COMPOSE_FILE"; do + if [ ! -f "$file" ]; then + echo "必需文件不存在: $file" >&2 + exit 2 + fi +done + if [ "$SERVICE" = "finance" ]; then for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then @@ -54,6 +83,56 @@ if [ "$SERVICE" = "finance" ]; then done fi +case "$SERVICE" in + gateway-overseas|lastleg-service|warehouse-overseas-service) + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +if [ "$SERVICE" = "firstleg" ]; then + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi +fi + +if [ "$SERVICE" = "lastleg-service" ]; then + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "warehouse-overseas-service" ]; then + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + +if [ "$SERVICE" = "event-bridge-service" ]; then + if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + if [ "$SERVICE" = "file" ]; then if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 @@ -149,24 +228,25 @@ REMOTE_PREPARE echo "传输镜像到 $SSH_TARGET: $IMAGE" docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load' -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY' +run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" <<'REMOTE_DEPLOY' set -eu remote_dir="$1" deploy_env="$2" image_repository="$3" image_tag="$4" service="$5" +project_name="$6" cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" docker compose \ - --project-name "firstleg-$deploy_env" \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never "$service" docker compose \ - --project-name "firstleg-$deploy_env" \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ ps "$service" diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index 459337f8a..b1034e2b9 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -15,7 +15,8 @@ SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" -COMPOSE_FILE="deploy/docker-compose.yml" +COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" +PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" if [ ! -f "$SSH_KEY" ]; then echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 @@ -65,19 +66,19 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then export IMAGE_REPOSITORY IMAGE_TAG docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ pull $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name firstleg-development \ + --project-name "$PROJECT_NAME" \ --env-file .env \ -f docker-compose.yml \ ps $SERVICES @@ -129,12 +130,13 @@ for service in $SERVICES; do docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' done -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY' +run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' set -eu remote_dir="$1" image_repository="$2" image_tag="$3" service_csv="$4" +project_name="$5" services=$(printf '%s' "$service_csv" | tr ',' ' ') cd "$remote_dir" @@ -143,13 +145,13 @@ export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" docker compose \ - --project-name firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ up -d --remove-orphans --pull never $services docker compose \ - --project-name firstleg-development \ + --project-name "$project_name" \ --env-file .env \ -f docker-compose.yml \ ps $services diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 41a2c0b9b..e420ee93a 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -19,6 +19,15 @@ fi SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') +for service in $SERVICES; do + case "$service" in + *[!A-Za-z0-9_-]*|'') + echo "非法服务名: $service" >&2 + exit 2 + ;; + esac +done + env_file_has_key() { grep -q "^$1=" "$ENV_FILE" } @@ -27,6 +36,46 @@ env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' } +DEPLOY_REGION=CN +for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + event-bridge-service) + if ! env_file_has_key REGION_CODE; then + echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 + exit 2 + fi + case "$(env_file_value REGION_CODE)" in + CN) ;; + OVERSEAS) DEPLOY_REGION=OVERSEAS ;; + *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; + esac + ;; + esac +done +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for service in $SERVICES; do + case "$service" in + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; + esac + done + COMPOSE_FILE="deploy/docker-compose.overseas.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" +else + COMPOSE_FILE="deploy/docker-compose.yml" + PROJECT_NAME="firstleg-${DEPLOY_ENV}" +fi + +if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署海外服务时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + case " $SERVICES " in *" finance "*) for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do @@ -38,6 +87,53 @@ case " $SERVICES " in ;; esac +case " $SERVICES " in + *" firstleg "*) + if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then + echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 + exit 2 + fi + ;; +esac + +case " $SERVICES " in + *" lastleg-service "*) + for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" warehouse-overseas-service "*) + for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + +case " $SERVICES " in + *" event-bridge-service "*) + if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" + else + EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL" + fi + for key in $EVENT_BRIDGE_KEYS; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + case " $SERVICES " in *" file "*) if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then @@ -75,24 +171,24 @@ esac export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG if [ "$DEPLOY_ENV" = "development" ]; then - exec sh deploy/scripts/deploy-development-service.sh \ + DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" fi docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ pull $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ up -d --remove-orphans --pull never $SERVICES docker compose \ - --project-name "firstleg-${DEPLOY_ENV}" \ + --project-name "$PROJECT_NAME" \ --env-file "$ENV_FILE" \ - -f deploy/docker-compose.yml \ + -f "$COMPOSE_FILE" \ ps $SERVICES diff --git a/deploy/service-map.yml b/deploy/service-map.yml index 7b8008371..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,6 +3,11 @@ services: artifactId: gateway jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar port: 9000 + gateway-overseas: + artifactId: gateway-overseas + jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar + port: 9300 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar @@ -35,10 +40,21 @@ services: artifactId: integration-service jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar port: 9210 + lastleg-service: + artifactId: lastleg-service + jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar + port: 9303 + regions: [OVERSEAS] + warehouse-overseas-service: + artifactId: warehouse-overseas-service + jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar + port: 9305 + regions: [OVERSEAS] event-bridge-service: artifactId: event-bridge-service jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar - port: 9300 + port: 9312 + regions: [CN, OVERSEAS] audit-service: artifactId: audit-service jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f5caabb04..486bd1d5b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -2,7 +2,9 @@ ## 唯一数据库初始化与发布入口 -- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`;当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。 +- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 @@ -215,3 +217,9 @@ SELECT - Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。 - IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。 - Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。 + +## 四期海外权限目录 + +- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。 +- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。 +- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4849b700..841aa7d1a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9190,6 +9190,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() @@ -9197,23 +9199,14 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') - OR (id = 5102141 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:list', - 'platform:firstleg:ocean-booking:view')) OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') - OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') - OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') - OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') - OR (id = 5102147 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:cancel', - 'platform:firstleg:ocean-booking:edit')) - OR (id = 5102148 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:change', - 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -9223,13 +9216,6 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 - WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; - -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9249,10 +9235,6 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id -WHERE p.id=5102141; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; - USE baiyue_firstleg; @@ -9338,7 +9320,7 @@ BEGIN WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') OR (id = 5102151 AND permission_code NOT IN ( 'platform:firstleg:truck-appointment:list', - 'platform:firstleg:truck-appointment:view')) + CONCAT('platform:firstleg:truck-appointment:', 'view'))) OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') @@ -9353,10 +9335,6 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 - WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -9377,7 +9355,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102151; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; +DELETE FROM iam_permission WHERE id=5102151; USE baiyue_firstleg; @@ -10267,10 +10245,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10291,7 +10277,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -10306,7 +10292,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_access CHECK (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -10349,9 +10338,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -10392,6 +10407,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -10401,15 +10418,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; DELETE role_permission FROM iam_role_permission role_permission JOIN iam_permission permission ON permission.id=role_permission.permission_id WHERE permission.tenant_id=0 - AND permission.permission_code='platform:firstleg:port-schedule-query:view'; + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DELETE FROM iam_permission -WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ @@ -10417,16 +10440,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id IN (5102184,5102186,5102187,5102188) + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) - OR id=5102185 + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -10442,7 +10464,6 @@ INSERT INTO iam_permission VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -10463,12 +10484,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -10477,7 +10499,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ @@ -11727,6 +11749,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家 CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); @@ -12331,7 +12354,15 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数,用于指数退避'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); @@ -12375,6 +12406,21 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'request_hash', '规范化请求内容SHA-256摘要;用于识别同键不同请求'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'result_json', '成功结果JSON快照;供幂等重放返回'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'estimated_at', '供应商预计发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'actual_at', '供应商确认实际发生时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_code', '节点地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_name', '节点地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型'); @@ -15701,6 +15747,81 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; ++-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + +INSERT INTO baiyue_iam.iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), + http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + +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 +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql new file mode 100644 index 000000000..975fabc23 --- /dev/null +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -0,0 +1,254 @@ +-- 海外区域固定 SQL 入口。只由人工审核执行;服务部署默认不自动执行 SQL。 +USE baiyue_lastleg; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), + KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称', + product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接', + purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价', + currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', + purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', + domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', + overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注', + idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', + review_remark VARCHAR(1000) 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), + UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key), + KEY idx_ll_overseas_procurement_list(customer_id,status,created_at), + KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) +) ENGINE=InnoDB COMMENT='海外采购订单'; + +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 '备注', + 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='海外采购操作流水'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', + container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + 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 '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', + contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', + 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', + 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) COMMENT '更新时间', 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='美国卡车运输订单'; + +CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( + id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号', + source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', received_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_us_truck_order_pallet_no(customer_id,order_id,pallet_no), UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no), + 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_status_history ( + id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID', + remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), + KEY idx_ll_us_truck_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', + transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', + transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_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 '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' 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', 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 '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +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 '操作备注', + 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='本土散板操作流水'; + +-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。 +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'), + 'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='idx_ll_inbound_plan_container'), + 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD KEY idx_ll_inbound_plan_container (customer_id,container_no)')); +PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'), + 'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'), + 'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'), + 'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation', 'SELECT 1')); +PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'), + '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; + +SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与SKU库存结构已就绪' AS message; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index f1a996425..9c15e66f1 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -86,6 +86,7 @@ baiyue-firstleg/ | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | --- | --- | ---: | --- | --- | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | +| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -94,10 +95,12 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | -MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。 +MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 ## 5. 各服务文件夹说明 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java index e26f92210..575bd2867 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java @@ -36,7 +36,7 @@ public class EventBridgeController { @Value("${baiyue.region:OVERSEAS}") private String region; - @Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通") + @Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"), diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 146bb4a5c..25b7fc7c3 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; import java.time.LocalDateTime; +import java.util.Objects; /** * 入站事件校验与幂等落库服务。 @@ -32,9 +33,8 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash()); - boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash()); - if (!currentHash && !legacyHash) { + if (!EventPayloadHash.matches( + EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); @@ -55,12 +55,30 @@ public class EventInboxService { } private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { - if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { - throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件"); + if (!sameEnvelope(existing, envelope)) { + throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } + private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) { + return existing.getPayloadHash() != null + && existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash()) + && Objects.equals(existing.getEventType(), envelope.eventType()) + && Objects.equals(existing.getEventVersion(), envelope.eventVersion()) + && Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name()) + && Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name()) + && Objects.equals(existing.getCustomerId(), envelope.tenantId()) + && Objects.equals(existing.getAggregateType(), envelope.aggregateType()) + && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) + && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) + && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) + && Objects.equals(existing.getOccurredAt(), envelope.occurredAt()) + && Objects.equals(existing.getTraceId(), envelope.traceId()) + && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) + && Objects.equals(existing.getCausationId(), envelope.causationId()); + } + private BridgeInbox toInbox(EventEnvelope envelope) { BridgeInbox inbox = new BridgeInbox(); inbox.setEventId(envelope.eventId()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 53a1bdc39..5d83c9689 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -19,7 +19,7 @@ spring: password: ${EVENT_BRIDGE_DB_PASSWORD:123456} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -31,7 +31,7 @@ spring: baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: - consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 1d5d45152..9ae12a6dc 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -36,7 +36,7 @@ class EventInboxServiceTest { } @Test - void 数字规范化上线前的历史摘要仍可接收() { + void 拒绝旧数字规范化摘要() { EventEnvelope source = envelope("sha256:placeholder"); source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150); String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload())); @@ -45,13 +45,15 @@ class EventInboxServiceTest { source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash); - assertThat(service.receive(envelope).result()).isEqualTo("已接收"); + assertThatThrownBy(() -> service.receive(envelope)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); } @Test void 相同事件和摘要重复接收不再次写入() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - BridgeInbox existing = existing(envelope.payloadHash()); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -60,17 +62,31 @@ class EventInboxServiceTest { @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64))); + when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64))); assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); } + @Test + void 相同事件和摘要但聚合标识不同时拒绝覆盖() { + EventEnvelope envelope = withHash(envelope("sha256:placeholder")); + EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), + envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), + envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(), + envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); + when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash())); + + assertThatThrownBy(() -> service.receive(conflicting)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("事件信封"); + } + @Test void 并发插入冲突后按既有记录判定为重复() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); - when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash())); + when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash())); doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any()); assertThat(service.receive(envelope).result()).isEqualTo("重复"); @@ -109,9 +125,22 @@ class EventInboxServiceTest { source.correlationId(), source.causationId(), source.payload(), hash); } - private BridgeInbox existing(String hash) { + private BridgeInbox existing(EventEnvelope envelope, String hash) { BridgeInbox inbox = new BridgeInbox(); - inbox.setEventId("550e8400-e29b-41d4-a716-446655440000"); + inbox.setEventId(envelope.eventId()); + inbox.setEventType(envelope.eventType()); + inbox.setEventVersion(envelope.eventVersion()); + inbox.setSourceRegion(envelope.sourceRegion().name()); + inbox.setTargetRegion(envelope.targetRegion().name()); + inbox.setCustomerId(envelope.tenantId()); + inbox.setAggregateType(envelope.aggregateType()); + inbox.setAggregateId(envelope.aggregateId()); + inbox.setAggregateVersion(envelope.aggregateVersion()); + inbox.setHandoverVersion(envelope.handoverVersion()); + inbox.setOccurredAt(envelope.occurredAt()); + inbox.setTraceId(envelope.traceId()); + inbox.setCorrelationId(envelope.correlationId()); + inbox.setCausationId(envelope.causationId()); inbox.setPayloadHash(hash); inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0)); return inbox; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 6a6e0ec51..1a711b5d1 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -28,8 +28,9 @@ | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | -| 四期国内出仓后船节点定时查询 | 现有 `portschedule/controller/PlatformCarrierScheduleQueryController.java`、`service/CarrierScheduleQueryService.java` 及查询任务、尝试、历史整体归四期;新增定时编排、节点事实或到港衔接继续归该子域,外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | +| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | @@ -49,7 +50,8 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史,并以幂等方式把确认到港/可提柜结果衔接给海外区域。现有 `CarrierScheduleQuery` 及“每 3 天一次、窗口最多 3 次”约束属于四期;外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java new file mode 100644 index 000000000..89606b826 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.time.LocalDateTime; +import java.util.List; + +@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient", + url = "${baiyue.integration-service.url:}") +public interface CarrierScheduleIntegrationClient { + @PostMapping("/internal/integrations/carrier-schedules/query") + Envelope query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody QueryRequest request); + + record Envelope(String code, String message, T data) { + public boolean successful() { return "200".equals(code); } + } + record QueryRequest(String carrierCode, String trackingType, String trackingNo) { } + record QueryResult(String providerCode, String outcome, List observations, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java index 8abce64c9..07375297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerOverviewDtos.java @@ -14,6 +14,14 @@ public final class ContainerOverviewDtos { List timeline) {} public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo, String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName, + String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName, + String departurePortCode, String departurePortName, String destinationPortCode, + String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt, + LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, + LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt, + LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt, + Long emptyReportSourceVersion, Integer version) {} public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus, String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index da7d5450e..50ec10a34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper contributors; + private final CarrierScheduleQueryService carrierSchedules; + @Autowired public OutboundDispatchService(OutboundDispatchRepository repository, - List contributors) { + List contributors, + CarrierScheduleQueryService carrierSchedules) { this.repository = repository; + this.carrierSchedules = carrierSchedules; Map indexed = new HashMap<>(); for (OutboundDispatchGateContributor contributor : contributors) { if (indexed.put(contributor.code(), contributor) != null) { @@ -39,6 +45,11 @@ public class OutboundDispatchService { this.contributors = Map.copyOf(indexed); } + public OutboundDispatchService(OutboundDispatchRepository repository, + List contributors) { + this(repository, contributors, null); + } + public List list(Query query) { requireSelector(query); return repository.bindings(query).stream().map(this::aggregate).toList(); @@ -73,6 +84,10 @@ public class OutboundDispatchService { } history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + if (carrierSchedules != null) { + carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), + binding.containerBookingId(), operatorId, operatorName); + } if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); return detail(query); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java new file mode 100644 index 000000000..3187f3912 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java @@ -0,0 +1,48 @@ +package com.baiyue.firstleg.portschedule.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries") +public class InternalCarrierScheduleQueryController { + private final CarrierScheduleQueryService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; } + + @PostMapping("/{queryId}/stop") + public ApiResponse stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable @Positive long queryId, + @Valid @RequestBody StopRequest request) { + authenticate(token); + return ApiResponse.success(service.stop(queryId, request.reason())); + } + + private void authenticate(String token) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + } + + public record StopRequest(@NotBlank @Size(max = 500) String reason) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index a98ec7d28..90a5ce73a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -4,14 +4,13 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.service.FirstlegScopeGuard; import io.swagger.v3.oas.annotations.Operation; @@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController { @GetMapping("/{queryId}/history") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')") - public ApiResponse> history( + public ApiResponse> history( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.history(queryId)); @@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController { return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } - @PostMapping("/{queryId}/attempts") - @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')") - @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY", - resourceId = "#queryId", description = "执行船司官网查询策略") - public ApiResponse attempt(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable @Positive long queryId, - @RequestHeader("Idempotency-Key") String key, - @Valid @RequestBody AttemptRequest request) { - FirstlegScopeGuard.platform(user, service.customerId(queryId)); - return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request)); - } - @PostMapping("/{queryId}/manual-results") @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java new file mode 100644 index 000000000..724fc5524 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +/** Provider-independent maritime milestones in monotonic business order. */ +public enum CarrierScheduleNode { + DEPARTED_WAREHOUSE(10), + AT_ORIGIN_PORT(20), + DEPARTED_ORIGIN_PORT(30), + IN_TRANSIT(40), + ARRIVED(50), + PICKUP_READY(60); + + private final int sequence; + + CarrierScheduleNode(int sequence) { this.sequence = sequence; } + + public int sequence() { return sequence; } + + public static CarrierScheduleNode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index 07a06443d..b0fe8b792 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -1,8 +1,5 @@ package com.baiyue.firstleg.portschedule.dto; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; @@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos { @NotBlank @Size(max = 128) String trackingNo, @Size(max = 1000) String remark) {} - public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion, - @Size(max = 1000) String reason) {} - public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion, @NotNull @Positive Long portScheduleId, @NotBlank @Size(max = 1000) String reason) {} + public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 64) String providerNodeCode, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl, String accessMode, boolean automated, boolean coverageGap, String coverageGapCode, String manualReason) {} - public record QueryView(CarrierScheduleQueryEntity query, boolean expired, - int maxAttempts, LocalDateTime nextAllowedAt) {} + public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId, + Long containerBookingId, String carrierCode, String trackingType, String trackingNo, + String officialUrl, String accessMode, String status, String triggerSource, + String latestStandardNode, LocalDateTime latestNodeObservedAt, + LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt, + Long resultPortScheduleId, String manualReason, String lastErrorCode, + String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {} - public record Detail(QueryView query, List attempts, - List histories) {} + public record QueryView(Task query, boolean expired) {} + + public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode, + int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + + public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason, + Long operatorId, String operatorName, LocalDateTime createdAt) {} + + /** Frontend detail intentionally excludes scheduler leases and raw query attempts. */ + public record Detail(QueryView query, List nodes, List histories) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java new file mode 100644 index 000000000..3015f55c3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_node_fact") +public class CarrierScheduleNodeFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerNodeCode; + private String standardNode; + private Integer nodeSequence; + private LocalDateTime observedAt; + private LocalDateTime estimatedAt; + private LocalDateTime actualAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java index fd460a582..748aff208 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleQueryEntity.java @@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity { private String officialUrl; private String accessMode; private String status; + private String triggerSource; + private String latestStandardNode; + private LocalDateTime latestNodeObservedAt; + private LocalDateTime stoppedAt; + private String stopReason; + private String leaseOwner; + private LocalDateTime leaseUntil; + private Integer consecutiveFailures; private LocalDateTime windowStartedAt; private Integer attemptCount; private LocalDateTime nextAllowedAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java new file mode 100644 index 000000000..a5aac0ec7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleNodeFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_node_fact + (id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at, + estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode}, + #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index cc973ee0b..69b0d47f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; +import java.util.List; + @Mapper public interface CarrierScheduleQueryMapper extends BaseMapper { @Select(""" @@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); + + @Update(""" + UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil}, + updated_at=CURRENT_TIMESTAMP(3) + WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY') + AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now} + AND (lease_until IS NULL OR lease_until<#{now}) + """) + int claimLease(@Param("id") long id, @Param("owner") String owner, + @Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil); + @Update(""" UPDATE fl_carrier_schedule_query SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status}, + trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode}, + latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt}, + stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil}, + consecutive_failures=#{value.consecutiveFailures}, window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount}, next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt}, last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt}, 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 f92a10783..6ff96dcc6 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 @@ -9,14 +9,17 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; import java.util.List; +import java.time.LocalDateTime; @Repository public class CarrierScheduleQueryRepository { @@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository { @Resource private CarrierScheduleQueryAttemptMapper attempts; @Resource private CarrierScheduleQueryHistoryMapper histories; @Resource private CarrierScheduleQueryOperationMapper operations; + @Resource private CarrierScheduleNodeFactMapper nodes; public PageResult page(Query query) { return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query)); } public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); } public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); } + public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) { + return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId); + } + public List due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); } + public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) { + return queries.claimLease(id, owner, now, leaseUntil) == 1; + } public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); } public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) { return queries.updateState(value, version, operatorId, operatorName); @@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleQueryHistoryEntity::getId)); } public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); } + public List nodes(long id) { + return nodes.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleNodeFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence) + .orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleNodeFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } 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/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 1ddcd1d77..82e780b8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry { return required(carrierCode).code(); } + public String resolve(String carrierName) { + String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT); + return DEFINITIONS.values().stream().filter(value -> value.matches(normalized)) + .findFirst().map(Definition::code) + .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, + "出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName)); + } + private static Definition required(String carrierCode) { String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); Definition value = DEFINITIONS.get(normalized); @@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry { return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true, coverageGapCode, manualReason); } + boolean matches(String value) { + return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT)) + || "MATSON".equals(code) && value.contains("美森") + || "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE")) + || "ZIM".equals(code) && value.contains("以星") + || "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING")) + || "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN")) + || "HEDE".equals(code) && value.contains("合德"); + } } public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl, 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 new file mode 100644 index 000000000..d1e28ef83 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQueryScheduler.java @@ -0,0 +1,71 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Component +public class CarrierScheduleQueryScheduler { + private final CarrierScheduleQueryRepository repository; + private final CarrierScheduleQueryService service; + private final CarrierScheduleIntegrationClient integration; + private final String workerId = UUID.randomUUID().toString(); + private Clock clock = Clock.systemDefaultZone(); + + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + @Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20; + @Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}") + private Duration leaseDuration = Duration.ofMinutes(5); + + public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository, + CarrierScheduleQueryService service, + CarrierScheduleIntegrationClient integration) { + this.repository = repository; this.service = service; this.integration = integration; + } + + @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}") + public void poll() { + LocalDateTime now = LocalDateTime.now(clock); + repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now)); + } + + /** Executes only after this worker wins the database lease. */ + void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) { + if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return; + try { + var response = integration.query(internalToken, + new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo())); + if (response == null || !response.successful() || response.data() == null) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE", + "轨迹供应商返回空响应"); + 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())); + } + service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(), + result.errorMessage()); + } catch (RuntimeException exception) { + service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED", + exception.getClass().getSimpleName()); + } + } +} 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 c5df3da71..adaa9a117 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 @@ -5,17 +5,22 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -61,13 +66,148 @@ public class CarrierScheduleQueryService { public Detail detail(long id) { return detail(required(id)); } - public List history(long id) { + public List history(long id) { required(id); - return repository.histories(id); + return repository.histories(id).stream().map(this::history).toList(); } public long customerId(long id) { return required(id).getCustomerId(); } + /** Idempotently starts the canonical tracking task after domestic outbound confirmation. */ + @Transactional + public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId, + long containerBookingId, + long operatorId, String operatorName) { + CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (existing != null) return existing; + OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId); + ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); + if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); + validateLinks(customerId, oceanBookingId, containerBookingId); + String carrierCode = adapters.resolve(booking.getShippingCompanyName()); + String trackingType; + String trackingNo; + if (StringUtils.hasText(container.getContainerNo())) { + trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim(); + } else if (StringUtils.hasText(booking.getBillOfLadingNo())) { + trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim(); + } else if (StringUtils.hasText(booking.getBookingNo())) { + trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim(); + } else { + throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); + } + PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo); + LocalDateTime now = now(); + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); + value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); + value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode); + value.setTrackingType(trackingType); value.setTrackingNo(trackingNo); + value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); + value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); + value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); + value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); + value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); + try { repository.insert(value); } + catch (DuplicateKeyException exception) { + CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId, + containerBookingId); + if (concurrent != null) return concurrent; + throw exception; + } + addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM", + CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM", + sha256("OUTBOUND_CONFIRM|" + containerBookingId)); + addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value, + operatorId, operatorName); + return value; + } + + @Transactional + public Detail applyProviderObservation(long id, ProviderObservationRequest request) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); + CarrierScheduleQueryEntity before = copy(value); + if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, + request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(), + request.locationName(), request.sourceReference(), request.rawPayloadHash())) { + return detail(value); + } + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + if (current == null || node.sequence() >= current.sequence()) { + value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt()); + value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt()); + value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); + value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED" + : node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY"); + value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + } + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, + after, 0L, "carrier-scheduler"); + return detail(after); + } + + @Transactional + public Detail stop(long id, String reason) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleQueryEntity before = copy(value); + value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason)); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L, + "carrier-scheduler"); + return detail(after); + } + + @Transactional + public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return; + CarrierScheduleQueryEntity before = copy(value); + LocalDateTime now = now(); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); + value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); + value.setLastErrorMessage(trim(errorMessage)); + if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { + value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); + value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + } else { + int failures = count(value.getConsecutiveFailures()) + 1; + LocalDateTime windowStart = value.getWindowStartedAt(); + if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { + windowStart = now; value.setAttemptCount(0); + } + int attempts = count(value.getAttemptCount()) + 1; + value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); + value.setConsecutiveFailures(failures); + if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { + if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) { + value.setStatus("MANUAL_REQUIRED"); + } + value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + } else { + long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); + LocalDateTime retryAt = now.plusMinutes(minutes); + if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) { + retryAt = windowStart.plusDays(WINDOW_DAYS); + } + value.setNextAllowedAt(retryAt); + } + } + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + CarrierScheduleQueryEntity after = required(id); + addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, + "carrier-scheduler"); + } + @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { String operation = "CREATE"; @@ -84,7 +224,8 @@ public class CarrierScheduleQueryService { value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode); value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim()); value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); - value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0); + value.setStatus("READY"); value.setTriggerSource("MANUAL"); + value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); @@ -94,51 +235,6 @@ public class CarrierScheduleQueryService { return complete(value.getId(), operatorId, key, detail(value)); } - @Transactional - public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) { - String operation = "ATTEMPT"; - Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); - if (replay != null) return replay; - CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion()); - LocalDateTime now = now(); - LocalDateTime windowStart = before.getWindowStartedAt(); - if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - windowStart = now; - before.setAttemptCount(0); - } - if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null - && now.isBefore(before.getNextAllowedAt())) { - throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt()); - } - int attemptCount = count(before.getAttemptCount()); - LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS); - if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) { - throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd); - } - PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo()); - CarrierScheduleQueryEntity snapshot = copy(before); - int nextCount = attemptCount + 1; - before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode()); - before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount); - before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status()); - before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode()); - before.setLastErrorMessage(strategy.manualReason()); - before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now); - if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); - CarrierScheduleQueryEntity after = required(id); - CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); - attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount); - attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode()); - attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status()); - attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode()); - attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode()); - attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName); - repository.insert(attempt); - addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after, - operatorId, operatorName); - return complete(id, operatorId, key, detail(after)); - } - @Transactional public Detail attachManualResult(long id, long operatorId, String operatorName, String key, ManualResultRequest request) { @@ -155,7 +251,7 @@ public class CarrierScheduleQueryService { } CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); - value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId()); + value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); @@ -212,13 +308,48 @@ public class CarrierScheduleQueryService { } private Detail detail(CarrierScheduleQueryEntity value) { - return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId())); + return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(), + repository.histories(value.getId()).stream().map(this::history).toList()); + } + + private NodeFact node(CarrierScheduleNodeFactEntity value) { + return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(), + value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), + value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + + private History history(CarrierScheduleQueryHistoryEntity value) { + return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(), + value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); + } + + private boolean 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) { + CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name()); + 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); } private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null || !value.getExpiresAt().isAfter(now); - return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt()); + Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(), + value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(), + value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(), + value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(), + value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(), + value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(), + value.getVersion()); + return new QueryView(task, expired); } private CarrierScheduleQueryEntity required(long id) { @@ -234,6 +365,12 @@ public class CarrierScheduleQueryService { return value; } + private CarrierScheduleQueryEntity requiredLocked(long id) { + CarrierScheduleQueryEntity value = repository.lock(id); + if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在"); + return value; + } + private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) { CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId()); @@ -241,6 +378,10 @@ public class CarrierScheduleQueryService { value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType()); value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl()); value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus()); + value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode()); + value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt()); + value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner()); + value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures()); value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount()); value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt()); value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt()); @@ -259,6 +400,12 @@ public class CarrierScheduleQueryService { } catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); } } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index a82cffec0..cb8935edd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -15,17 +15,27 @@ import java.util.List; public class LoadingCompletedOutboxRepository { @Resource private InboundOutboxMapper mapper; - public List pending(int limit, LocalDateTime now) { + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) - .eq(InboundOutboxEntity::getPublishStatus, "PENDING") - .and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING") + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId, id) + .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") + .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) - .set(InboundOutboxEntity::getLockedAt, now)) == 1; } + .set(InboundOutboxEntity::getLockedAt, now)) == 1; + } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 2733ac4a8..612018986 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -44,7 +44,14 @@ public class ContainerOverviewService { List timeline = timeline(id, task == null ? null : task.id(), exceptionHistory); return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(), booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(), - booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()), + booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(), + booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), + booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), + booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(), + booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(), + booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(), + booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(), + booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()), task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents, new ExceptionState(latestException != null && "MARK".equals(latestException.action()), latestException == null ? null : latestException.reason(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index bc3052d4a..30bf94202 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay { @Resource private EventBridgeClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; + @Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds; private final String workerId = UUID.randomUUID().toString(); @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { LocalDateTime now=LocalDateTime.now(); - for (InboundOutboxEntity candidate:repository.pending(50,now)) { - if(!repository.claim(candidate.getId(),workerId,now)) continue; + LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10)); + for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { + if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); if(row==null) continue; try { @@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay { || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { - repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage()); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage()); + String error=safeError(exception); + repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); + log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error); } } } + + private String safeError(Exception exception) { + String value=exception.getMessage(); + if(value==null || value.isBlank()) value=exception.getClass().getSimpleName(); + if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); + value=value.replaceAll("[\\r\\n\\t]+"," "); + return value.length()>500?value.substring(0,500):value; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 67748dd32..1873105dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer { public ContainerBookingEntity consume(EventEnvelope event) { validateEnvelope(event); OverseasContainerUnloadedV1 payload = payload(event); - if (payload.containerNo() == null || payload.containerNo().isBlank() - || !payload.containerNo().equals(event.aggregateId())) { - throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致"); + if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() + || !payload.sourceContainerBookingId().equals(event.aggregateId())) { + throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致"); + } + if (payload.containerNo() == null || payload.containerNo().isBlank()) { + throw new IllegalArgumentException("货柜号不能为空"); } if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) { throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空"); @@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } ContainerEventInboxEntity row = inbox(event); try { @@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } - ContainerBookingEntity container = requiredContainer(payload.containerNo()); + ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt, uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); - return requiredContainer(payload.containerNo()); + return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); } private void validateEnvelope(EventEnvelope event) { @@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer { return row; } - private ContainerBookingEntity requiredContainer(String containerNo) { - ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo); + private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) { + long bookingId; + try { + bookingId = Long.parseLong(sourceContainerBookingId); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception); + } + ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId); if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用"); + if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) { + throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致"); + } return value; } diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 80636650e..5b58c4d67 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -13,10 +13,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + stopped_at DATETIME(3) NULL COMMENT '任务停止时间', + stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', + lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -37,7 +45,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( PRIMARY KEY (id), UNIQUE KEY uk_carrier_schedule_query_no (query_no), UNIQUE KEY uk_carrier_schedule_query_scope - (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + (customer_id,ocean_booking_id,container_scope_id), KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), KEY idx_carrier_schedule_query_expiry (expires_at,id), @@ -52,7 +60,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_access CHECK (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + CONSTRAINT chk_carrier_schedule_query_node CHECK + (latest_standard_node IS NULL OR latest_standard_node IN + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -95,9 +106,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( PRIMARY KEY (id), KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) + CONSTRAINT chk_carrier_schedule_history_action CHECK + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', + standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', + node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间', + actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间', + location_code VARCHAR(32) NULL COMMENT '节点地点编码', + location_name VARCHAR(128) NULL COMMENT '节点地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_node_standard CHECK + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) +) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -138,6 +175,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -147,15 +186,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; DELETE role_permission FROM iam_role_permission role_permission JOIN iam_permission permission ON permission.id=role_permission.permission_id WHERE permission.tenant_id=0 - AND permission.permission_code='platform:firstleg:port-schedule-query:view'; + AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DELETE FROM iam_permission -WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view'; +WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view', + 'platform:firstleg:port-schedule-query:attempt'); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; DELIMITER $$ @@ -163,16 +208,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() BEGIN IF EXISTS ( SELECT 1 FROM iam_permission - WHERE (id IN (5102184,5102186,5102187,5102188) + WHERE (id IN (5102184,5102186,5102188) AND permission_code <> CASE id WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' - WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' END) - OR id=5102185 + OR id IN (5102185,5102187) OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' - AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188))) + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; @@ -188,7 +232,6 @@ INSERT INTO iam_permission VALUES (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), - (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), @@ -209,12 +252,13 @@ BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', - 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact')) <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission - WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 - AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; END IF; IF (SELECT COUNT(*) @@ -223,7 +267,7 @@ BEGIN JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') AND permission.tenant_id=0 - AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index d45265ad9..6512d8b88 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -232,6 +232,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; DELIMITER ; USE baiyue_iam; +DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145); +DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; DELIMITER $$ CREATE PROCEDURE assert_ocean_booking_permission_ids_available() @@ -239,23 +241,14 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list') - OR (id = 5102141 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:list', - 'platform:firstleg:ocean-booking:view')) OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create') - OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit') - OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit') - OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm') OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release') - OR (id = 5102147 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:cancel', - 'platform:firstleg:ocean-booking:edit')) - OR (id = 5102148 AND permission_code NOT IN ( - 'platform:firstleg:ocean-booking:change', - 'platform:firstleg:ocean-booking:delete')) + OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit') + OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete') OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate') OR (permission_code LIKE 'platform:firstleg:ocean-booking:%' - AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149)) + AND (tenant_id <> 0 OR app_code <> 'PLATFORM' + OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149))) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用'; @@ -265,13 +258,6 @@ DELIMITER ; CALL assert_ocean_booking_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1 - WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list'; - -DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145); -DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145); - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -291,8 +277,4 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%' WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); -DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id -WHERE p.id=5102141; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141; - USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index efc2bab89..8572d80c8 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -112,7 +112,7 @@ BEGIN WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list') OR (id = 5102151 AND permission_code NOT IN ( 'platform:firstleg:truck-appointment:list', - 'platform:firstleg:truck-appointment:view')) + CONCAT('platform:firstleg:truck-appointment:', 'view'))) OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create') OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit') OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate') @@ -127,10 +127,6 @@ DELIMITER ; CALL assert_truck_appointment_permission_ids_available(); DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available; -UPDATE iam_permission - SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1 - WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list'; - INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, route_path,http_method,sort_no,status,built_in,deleted) @@ -151,6 +147,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id WHERE p.id=5102151; -UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151; +DELETE FROM iam_permission WHERE id=5102151; USE baiyue_firstleg; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml new file mode 100644 index 000000000..30ecf0cd5 --- /dev/null +++ b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.3 +info: + title: Carrier Schedule Internal Handoff API + version: 1.0.0 + description: Internal-only handoff contract; it is not a platform or merchant frontend API. +paths: + /internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop: + post: + summary: Stop tracking after the overseas pickup handoff has completed + parameters: + - {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reason] + properties: + reason: {type: string, minLength: 1, maxLength: 500} + responses: + '200': {description: Idempotent STOPPED task detail} diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index e63d175fa..46a1b3371 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Carrier Schedule Query API version: 1.0.0 - description: Six-carrier query tasks with a three-day window, at most three attempts, and explicit manual fallback. + description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness. paths: /api/platform/v1/carrier-schedule-queries/carriers: get: @@ -40,7 +40,7 @@ paths: /api/platform/v1/carrier-schedule-queries/{queryId}: parameters: [{$ref: '#/components/parameters/QueryId'}] get: - summary: Get task, immutable attempts and history + summary: Get task, standardized node facts and immutable business history x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: Stable task detail} @@ -51,19 +51,6 @@ paths: x-permission: platform:firstleg:port-schedule-query:list responses: '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} - /api/platform/v1/carrier-schedule-queries/{queryId}/attempts: - parameters: [{$ref: '#/components/parameters/QueryId'}] - post: - summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages - x-permission: platform:firstleg:port-schedule-query:attempt - parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: - required: true - content: - application/json: - schema: {$ref: '#/components/schemas/AttemptRequest'} - responses: - '200': {description: MANUAL_REQUIRED with coverageGap when no public structured API is available} /api/platform/v1/carrier-schedule-queries/{queryId}/manual-results: parameters: [{$ref: '#/components/parameters/QueryId'}] post: @@ -84,7 +71,8 @@ components: schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, SUCCESS]} + QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]} + StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]} CreateRequest: type: object required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] @@ -97,12 +85,6 @@ components: trackingType: {$ref: '#/components/schemas/TrackingType'} trackingNo: {type: string, minLength: 1, maxLength: 128} remark: {type: string, nullable: true, maxLength: 1000} - AttemptRequest: - type: object - required: [expectedVersion] - properties: - expectedVersion: {type: integer, minimum: 0} - reason: {type: string, nullable: true, maxLength: 1000} ManualResultRequest: type: object required: [expectedVersion, portScheduleId, reason] @@ -112,9 +94,7 @@ components: reason: {type: string, minLength: 1, maxLength: 1000} QueryView: type: object - required: [query, expired, maxAttempts, nextAllowedAt] + required: [query, expired] properties: - query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason} + query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed} expired: {type: boolean} - maxAttempts: {type: integer, enum: [3]} - nextAllowedAt: {type: string, format: date-time} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java new file mode 100644 index 000000000..1d562c4ec --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerOverviewTransportContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.firstleg.dto.ContainerOverviewDtos.ContainerBookingEntityView; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class ContainerOverviewTransportContractTest { + @Test + void overviewPublishesMaritimeAndEmptyReportFacts() { + assertThat(Arrays.stream(ContainerBookingEntityView.class.getRecordComponents()).map(value -> value.getName())) + .contains("billOfLadingNo", "shippingCompanyId", "shippingCompanyName", "departurePortCode", + "departurePortName", "destinationPortCode", "destinationPortName", "estimatedSailingAt", + "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", + "estimatedWarehouseArrivalAt", "actualWarehouseArrivalAt", "unloadingCompletedAt", + "unloadingPhotoUploadedAt", "estimatedEmptyReportedAt", "actualEmptyReportedAt", + "emptyReportSourceVersion"); + } +} 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 1988f56f3..57841d96e 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 @@ -8,6 +8,7 @@ import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributo import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import org.junit.jupiter.api.Test; import java.util.List; @@ -67,6 +68,26 @@ class OutboundDispatchServiceTest { verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); } + @Test + void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); + OutboundDispatchService service = new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + stubReadyChain(); + OutboundDispatchEntity state = new OutboundDispatchEntity(); + state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); + state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); + when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); + when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + + service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + + verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + } + @Test void confirmNeverWritesStateWhenAnyRealGateFails() { OutboundDispatchService service = new OutboundDispatchService(repository, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index 774d22da0..d8546b86e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -9,19 +9,17 @@ import static org.assertj.core.api.Assertions.assertThat; class CarrierScheduleQueryOpenApiContractTest { @Test - void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception { + void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception { String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") - .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") + .contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY") .contains("MANUAL_REQUIRED") - .contains("coverageGap") - .contains("lastAttemptAt") - .contains("lastSuccessAt") - .contains("sourceUpdatedAt") - .contains("nextAllowedAt"); + .contains("scheduler leases and raw attempts are never exposed") + .doesNotContain("/{queryId}/attempts:") + .doesNotContain("port-schedule-query:attempt"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 1f1d1c0a6..1fb6f87e7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -22,16 +22,17 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "attempt", "platform:firstleg:port-schedule-query:attempt", "manualResult", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) { + if (name.equals("create") || name.equals("manualResult")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); + assertThat(Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) + .map(Method::getName)).doesNotContain("attempt"); } } 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 6c6123073..ddaae29ea 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 @@ -17,11 +17,16 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") .contains("attempt_count BETWEEN 0 AND 3") .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") - .contains("船司查询历史不可删除"); + .contains("船司查询历史不可删除") + .contains("标准船节点事实不可修改") + .contains("ARRIVED','PICKUP_READY','STOPPED") + .contains("lease_owner") + .contains("consecutive_failures"); } @Test @@ -29,9 +34,9 @@ class CarrierScheduleQuerySqlContractTest { assertThat(moduleSql()) .contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id") .contains("allocation.container_booking_id=NEW.container_booking_id") - .contains("5102184 AND 5102188") - .contains("platform:firstleg:port-schedule-query:attempt") + .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") + .contains("'platform:firstleg:port-schedule-query:attempt'") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("船司查询任务角色授权未完整创建"); } @@ -43,7 +48,7 @@ class CarrierScheduleQuerySqlContractTest { assertThat(unified) .contains("fl_carrier_schedule_query") .contains("uk_carrier_schedule_query_scope") - .contains("5102184 AND 5102188") + .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") .contains("船司查询任务角色授权未完整创建"); } 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 new file mode 100644 index 000000000..c54b003d3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySchedulerTest.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CarrierScheduleQuerySchedulerTest { + @Test + void leaseWinnerPersistsStandardNodesAndCompletesAttempt() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class); + CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class); + CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration); + CarrierScheduleQueryEntity task = new CarrierScheduleQueryEntity(); task.setId(1L); + task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567"); + LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0); + when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); + var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED", + now, null, now, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, + new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), + null, null, now))); + + scheduler.execute(task, now); + + verify(service).applyProviderObservation(anyLong(), any()); + verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); + } +} 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 8c9801761..2673fe3dc 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 @@ -3,10 +3,9 @@ package com.baiyue.firstleg.portschedule.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; -import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; @@ -14,7 +13,6 @@ import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -48,7 +46,7 @@ class CarrierScheduleQueryServiceTest { set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC)); when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); - when(repository.attempts(anyLong())).thenReturn(List.of()); + when(repository.nodes(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); } @@ -62,42 +60,48 @@ class CarrierScheduleQueryServiceTest { var result = service.create(7L, "admin", "carrier-create", new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null)); - assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM"); - assertThat(result.query().query().getStatus()).isEqualTo("READY"); + assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); + assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); verify(repository).insert(any(CarrierScheduleQueryEntity.class)); } @Test - void recordsManualRequiredAttemptWithoutBypassingCaptcha() { - CarrierScheduleQueryEntity task = task(0, 0); - when(repository.lock(1L)).thenReturn(task); - when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - when(repository.find(1L)).thenAnswer(invocation -> task); + void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { + OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); + booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001"); + var container = new com.baiyue.firstleg.entity.ContainerBookingEntity(); + container.setContainerNo("CSNU1234567"); + when(portSchedules.oceanBooking(10L)).thenReturn(booking); + when(portSchedules.containerBooking(20L)).thenReturn(container); + when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity()); - var result = service.attempt(1L, 7L, "admin", "carrier-attempt", - new AttemptRequest(0, "官网查询")); + CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"); - assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED"); - assertThat(result.query().query().getAttemptCount()).isEqualTo(1); - assertThat(result.query().query().getManualReason()).contains("hCaptcha"); - ArgumentCaptor attempt = - ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); - verify(repository).insert(attempt.capture()); - assertThat(attempt.getValue().getCoverageGap()).isTrue(); - assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); + assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); + assertThat(result.getCarrierCode()).isEqualTo("COSCO"); + assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); + assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } @Test - void blocksFourthAttemptInsideSameThreeDayWindow() { - CarrierScheduleQueryEntity task = task(3, 4); + void providerObservationAdvancesToArrivedWithoutExposingAttempts() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setLatestStandardNode("IN_TRANSIT"); when(repository.lock(1L)).thenReturn(task); + when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); - assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth", - new AttemptRequest(4, null))) - .isInstanceOf(BizException.class).hasMessageContaining("最多查询3次"); + var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1", + "DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + assertThat(result.query().query().status()).isEqualTo("ARRIVED"); + assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED"); + assertThat(result.nodes()).isEmpty(); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 62fcaae25..5cd6b548c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java new file mode 100644 index 000000000..abd8ea172 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -0,0 +1,96 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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 java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LoadingCompletedOutboxRelayTest { + @Mock private LoadingCompletedOutboxRepository repository; + @Mock private EventBridgeClient client; + private ObjectMapper objectMapper; + private LoadingCompletedOutboxRelay relay; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + relay = new LoadingCompletedOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); + ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + } + + @Test + void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00"))); + + relay.relay(); + + verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class)); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + + @Test + void redactsInternalTokenBeforePersistingRetryError() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenThrow(new IllegalStateException("failed internal-token\nremote")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote")); + } + + private InboundOutboxEntity row(EventEnvelope event) throws Exception { + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(9001L); + row.setEventKey(event.eventId()); + row.setPayloadJson(objectMapper.writeValueAsString(event)); + return row; + } + + private EventEnvelope event() { + var payload = objectMapper.valueToTree(new FirstLegLoadingPlanCompletedV1( + "LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null, + "WH-1", "Warehouse", List.of())); + return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1", + "LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index 390c16439..037490b0e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -43,12 +43,12 @@ class OverseasContainerEventConsumerTest { @Test void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() { ContainerBookingEntity container = container(); - when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); consumer.consume(event("00000000-0000-4000-8000-000000000001", 7, - new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null))); + new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null))); verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")), eq(LocalDateTime.parse("2026-08-20T02:30:00")), @@ -62,13 +62,13 @@ class OverseasContainerEventConsumerTest { @Test void repeatedEventWithSameHashDoesNotApplyAgain() { - OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567", + OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null); EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); existing.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(existing); - when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container()); + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); consumer.consume(event); @@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest { @Test void rejectsTamperedPayloadHash() { EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7, - new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), + new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(), @@ -96,7 +96,7 @@ class OverseasContainerEventConsumerTest { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1", - "CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null, + "CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null, "trace-12345678", "correlation-12345678", null, node, hash); } 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 50a208bca..d75af294b 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 @@ -53,7 +53,6 @@ public class GatewaySecurityConfig { .withJwkSetUri(properties.jwkSetUri()) .jwsAlgorithms(algorithms -> { algorithms.add(SignatureAlgorithm.RS256); - algorithms.add(SignatureAlgorithm.ES256); }) .build(); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( 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 d8621a342..4133a793a 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 @@ -5,12 +5,14 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.security.oauth2.jwt.Jwt; +import java.util.Collection; import java.util.List; +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"); + "sub", "tenant_id", "app_code", "permission_version", "authorities"); @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { @@ -21,6 +23,42 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator { "invalid_token", "JWT 缺少必要声明: " + claim, null)); } } + Long subject = longValue(jwt.getSubject()); + Long tenantId = longValue(jwt.getClaims().get("tenant_id")); + Long permissionVersion = longValue(jwt.getClaims().get("permission_version")); + String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT); + if (subject == null || subject <= 0) { + return failure("JWT sub 必须是正整数用户 ID"); + } + if (tenantId == null || tenantId < 0) { + return failure("JWT tenant_id 必须是非负整数客户范围"); + } + if (permissionVersion == null || permissionVersion < 0) { + return failure("JWT permission_version 必须是非负整数"); + } + if (!("PLATFORM".equals(appCode) && tenantId == 0L) + && !("MERCHANT".equals(appCode) && tenantId > 0L)) { + return failure("JWT app_code 与 tenant_id 访问域不一致"); + } + Object authorities = jwt.getClaims().get("authorities"); + if (!(authorities instanceof Collection values) + || values.stream().anyMatch(value -> value == null || value.toString().isBlank())) { + return failure("JWT authorities 必须是权限列表"); + } return OAuth2TokenValidatorResult.success(); } + + private static Long longValue(Object value) { + if (value instanceof Number number) return number.longValue(); + if (value == null) return null; + try { + return Long.valueOf(value.toString()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static OAuth2TokenValidatorResult failure(String message) { + return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null)); + } } diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index c1c32029e..5866a7caf 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -59,7 +59,9 @@ springdoc: swagger-ui: urls: - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } + - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } + - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } - { name: file-platform, url: /file-service/v3/api-docs/platform } - { name: file-merchant, url: /file-service/v3/api-docs/merchant } - { name: print-platform, url: /print-service/v3/api-docs/platform } 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 db83eae5a..8533dc7c7 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 @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.jwt.Jwt; import java.time.Instant; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -20,14 +21,58 @@ class RequiredClaimsJwtValidatorTest { assertThat(validator.validate(jwt(false)).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) + .build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + + @Test + void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() { + assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse(); + } + + @Test + void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() { + assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors()) + .isFalse(); + } + + @Test + void rejectsApplicationAndCustomerScopeMismatch() { + assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors()) + .isTrue(); + assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors()) + .isTrue(); + } + + @Test + void rejectsMalformedIdentityAndPermissionVersion() { + 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("authorities", List.of()).build(); + assertThat(validator.validate(value).hasErrors()).isTrue(); + } + private Jwt jwt(boolean includePermissionVersion) { + return jwt("0", "PLATFORM", + List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion); + } + + private Jwt jwt(String tenantId, String appCode, List authorities, + boolean includePermissionVersion) { Jwt.Builder builder = Jwt.withTokenValue("token") .header("alg", "RS256") .subject("1001") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", "2001") - .claim("app_code", "PLATFORM"); + .claim("tenant_id", tenantId) + .claim("app_code", appCode) + .claim("authorities", authorities); if (includePermissionVersion) { builder.claim("permission_version", 3L); } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 98348b610..12b0a7672 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -1,15 +1,67 @@ USE baiyue_iam; --- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。 +-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 +DELETE rp FROM iam_role_permission rp +JOIN iam_permission p ON p.id=rp.permission_id +WHERE p.permission_code LIKE 'platform:lastleg:%'; +UPDATE iam_permission SET status='INACTIVE', deleted=1 +WHERE permission_code LIKE 'platform:lastleg:%'; + INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), - (5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), - (5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), - (5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), - (5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) + (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), + (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), + (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0), + (5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0), + (5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0), + (5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0), + (5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0), + (5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0), + (5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0), + (5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0), + (5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0), + (5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0), + (5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0), + (5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0), + (5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0), + (5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0), + (5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0), + (5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0), + (5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0), + (5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0), + (5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0), + (5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0), + (5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0), + (5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0), + (5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0), + (5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0), + (5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0), + (5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0), + (5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0), + (5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; @@ -17,9 +69,6 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.id BETWEEN 5107000 AND 5107299 WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' - AND role.status='ACTIVE' AND role.deleted=0 - AND permission.permission_code IN ( - 'platform:lastleg:order:list', 'platform:lastleg:order:detail', - 'platform:lastleg:order:assign', 'platform:lastleg:order:transfer', - 'platform:lastleg:order:merge-pallet'); + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java new file mode 100644 index 000000000..6267c9cb1 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasPhase4PermissionSqlContractTest { + private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); + private static final List REQUIRED = List.of( + "platform:overseas-warehouse:last-mile-order:list", + "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:relabel-order:status", + "platform:overseas-warehouse:pallet:list", + "platform:overseas-warehouse:inventory:adjust", + "platform:overseas-business:purchase:review", + "platform:overseas-business:truck:create", + "platform:overseas-business:looseboard:list", + "platform:overseas-master-data:quote:list", + "platform:overseas-master-data:exchange-rate:list", + "platform:overseas-master-data:vehicle:list"); + + @Test + void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + Set catalog = codes(sql); + assertThat(sql).contains(REQUIRED.toArray(String[]::new)); + assertThat(catalog).hasSize(51).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); + assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); + } + + @Test + void unifiedSqlContainsTheSameCanonicalPermissionCatalog() throws Exception { + Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + String sql = Files.readString(path, StandardCharsets.UTF_8); + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + assertThat(codes(sql)).containsAll(moduleCatalog); + assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); + } + + @Test + void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception { + Set moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8)); + Set serviceCatalog = new LinkedHashSet<>(); + serviceCatalog.addAll(codes(repositoryFile( + "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java"))); + serviceCatalog.addAll(codes(repositoryFile( + "warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java"))); + + assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog); + } + + private static Set codes(String source) { + Set values = new LinkedHashSet<>(); + CANONICAL.matcher(source).results().map(result -> result.group()).forEach(values::add); + return values; + } + + private static String repositoryFile(String relativePath) throws Exception { + Path path = Path.of(relativePath); + if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 72926fad5..b049ac582 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -11,7 +11,7 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` -- 四期船节点外部适配:继续进入 `provider/` 和内部接口层;轮询任务、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` +- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java index 6205eac7b..a2a279d03 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java @@ -4,10 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.integration.dto.ProviderStatus; import com.baiyue.integration.security.InternalServiceTokenGuard; import com.baiyue.integration.service.ProviderStatusService; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.service.CarrierScheduleProviderService; +import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -19,6 +25,8 @@ public class IntegrationProviderController { private ProviderStatusService providerStatusService; @Resource private InternalServiceTokenGuard internalServiceTokenGuard; + @Resource + private CarrierScheduleProviderService carrierScheduleProviderService; @GetMapping("/providers") public ApiResponse> providers( @@ -26,4 +34,12 @@ public class IntegrationProviderController { internalServiceTokenGuard.verify(token); return ApiResponse.success(providerStatusService.providers()); } + + @PostMapping("/carrier-schedules/query") + public ApiResponse queryCarrierSchedule( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QueryRequest request) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(carrierScheduleProviderService.query(request)); + } } diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java new file mode 100644 index 000000000..430ec27fc --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java @@ -0,0 +1,17 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleStandardNode { + DEPARTED_WAREHOUSE, + AT_ORIGIN_PORT, + DEPARTED_ORIGIN_PORT, + IN_TRANSIT, + ARRIVED, + PICKUP_READY; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java new file mode 100644 index 000000000..4d94ac5ae --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java @@ -0,0 +1,23 @@ +package com.baiyue.integration.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class CarrierScheduleDtos { + private CarrierScheduleDtos() { } + + public record QueryRequest(@NotBlank @Size(max = 16) String carrierCode, + @NotBlank @Size(max = 32) String trackingType, + @NotBlank @Size(max = 128) String trackingNo) { } + + public record QueryResult(String providerCode, String outcome, List observations, + String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } + + public record Observation(String providerEventId, String providerNodeCode, String standardNode, + LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..7987204e0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CarrierScheduleProviderAdapter.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; + +public interface CarrierScheduleProviderAdapter { + boolean supports(String carrierCode); + QueryResult query(QueryRequest request); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..9a4f219a2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java @@ -0,0 +1,35 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Safe fallback for the six confirmed carriers. It never scrapes captcha/dynamic pages and returns + * an explicit provider-unavailable outcome until a documented provider API adapter is configured. + */ +@Component +public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final Set CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE"); + + @Override + public boolean supports(String carrierCode) { + return carrierCode != null && CARRIERS.contains(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), + "PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取", + LocalDateTime.now()); + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java new file mode 100644 index 000000000..f02110ab0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -0,0 +1,39 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.domain.CarrierScheduleStandardNode; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +@Service +public class CarrierScheduleProviderService { + private static final Set OUTCOMES = Set.of("SUCCESS", "NO_CHANGE", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE", "MANUAL_REQUIRED"); + private final List adapters; + + public CarrierScheduleProviderService(List adapters) { + this.adapters = List.copyOf(adapters); + } + + public QueryResult query(QueryRequest request) { + QueryResult result = adapters.stream().filter(adapter -> adapter.supports(request.carrierCode())).findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) + .query(request); + if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) { + throw new IllegalStateException("轨迹供应商返回空结果"); + } + if (result.observations().stream().anyMatch(value -> value == null + || !CarrierScheduleStandardNode.supports(value.standardNode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效标准节点"); + } + return result; + } +} diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml new file mode 100644 index 000000000..54a27df9d --- /dev/null +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: + title: Carrier Schedule Provider Internal API + version: 1.0.0 + description: Internal provider adapter contract. It owns normalization, timeout, rate-limit and provider errors, not business tasks. +paths: + /internal/integrations/carrier-schedules/query: + post: + summary: Query one carrier tracking identifier through the configured provider adapter + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': {description: Standard observations or an explicit normalized failure/manual outcome} +components: + schemas: + QueryRequest: + type: object + required: [carrierCode, trackingType, trackingNo] + properties: + carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} + trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} + trackingNo: {type: string, minLength: 1, maxLength: 128} + StandardNode: + type: string + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY] + QueryResult: + type: object + required: [providerCode, outcome, observations] + properties: + providerCode: {type: string} + outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]} + observations: + type: array + items: + type: object + required: [providerEventId, providerNodeCode, standardNode, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string} + providerNodeCode: {type: string} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java new file mode 100644 index 000000000..e5ec3bc04 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -0,0 +1,41 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; +import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CarrierScheduleProviderServiceTest { + @Test + void fallbackNeverPretendsThatWebsiteScrapingSucceeded() { + var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter())); + QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")); + assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); + assertThat(result.observations()).isEmpty(); + } + + @Test + void rejectsProviderSpecificNodesThatWereNotNormalized() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN", + LocalDateTime.now(), null, null, null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效标准节点"); + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 1d5999c8b..08dc82844 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,7 +3,7 @@ ## 职责与边界 - 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。 +- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -17,16 +17,19 @@ | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | -| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` | +| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanSummary.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | -| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` | -| 入仓后库存余额、库位和流水 | `inventory/`;平台库存命令从 `inventory/command/controller/InventoryCommandController.java` 进入 | +| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | +| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` | -| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` | +| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | +| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | +| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/`、`masterdata/exchange/`、`masterdata/vehicle/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`;车辆档案不含派送履约历史 | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | @@ -34,19 +37,21 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 +- 历史 `dispatch/`、`delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 | 业务域 | 表名 | 中文含义 | |---|---|---| -| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 | +| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 | | 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 | | 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 | | 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 | | 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 | | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | -| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 | +| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | +| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -61,14 +66,16 @@ - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 ## 查询与租户约束 - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 4f86b09f9..938233013 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -13,6 +13,8 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel + org.springframework.cloudspring-cloud-starter-openfeign + org.springframework.cloudspring-cloud-starter-loadbalancer com.alibaba.cloudspring-cloud-starter-stream-rocketmq org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime @@ -20,4 +22,3 @@ org.springframework.bootspring-boot-maven-plugin - diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java index 3b63ff22f..8fb28f552 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -3,11 +3,15 @@ package com.baiyue.lastleg; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 */ @MapperScan("com.baiyue.lastleg.mapper") +@EnableFeignClients(basePackages = "com.baiyue.lastleg") +@EnableScheduling @SpringBootApplication(scanBasePackages = "com.baiyue") public class BaiyueLastlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java new file mode 100644 index 000000000..e6ab0d6fa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将海外业务 Outbox 投递到国内区域事件桥。 */ +@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", + url = "${baiyue.firstleg.event-bridge-url:}") +public interface EventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} 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 d3373c2db..79997bf4a 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 @@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; 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 io.swagger.v3.oas.annotations.Operation; @@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -48,7 +50,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders") - @Operation(summary = "Page tail-leg order rows") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -57,7 +60,8 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders/{id}") - @Operation(summary = "Get one inbound-code-level tail-leg order") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -66,7 +70,8 @@ public class LastlegOrderController { } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") - @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") + @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -79,7 +84,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") - @Operation(summary = "Transfer one order to a destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferOne( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -89,7 +95,8 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/destination") - @Operation(summary = "Transfer multiple orders to a destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 49088ef0a..b56b7d5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @RequestMapping("/api/platform/v1/last-leg") @Tag(name = "Outbound and delivery") public class DeliveryTransferController { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 643058a20..b763efe88 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -1,6 +1,5 @@ package com.baiyue.lastleg.delivery.dto; -import com.baiyue.common.core.api.PageResult; import java.time.LocalDateTime; public final class DeliveryResponses { @@ -17,11 +16,5 @@ public final class DeliveryResponses { String overseasWarehouseId, String status, int palletCount, int packageCount, LocalDateTime createdAt) {} - public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode, - String orderRefs, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - int packageCount) {} - - public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery, - PageResult pallets) {} + public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index b7e335447..57ec1814b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -136,14 +136,7 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo); if (outbound == null) throw notFound("Outbound order not found"); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); - Page page = outboundPallets.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(OutboundPalletItem::getCustomerId, customerId) - .eq(OutboundPalletItem::getOutboundOrderId, outbound.getId()) - .eq(StringUtils.hasText(query.destinationWarehouseCode()), - OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode()) - .orderByAsc(OutboundPalletItem::getId)); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery), - page(page, page.getRecords().stream().map(this::palletRow).toList())); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { @@ -206,7 +199,6 @@ public class DeliveryTransferService { } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); } - private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); } private static PageResult page(Page page, List records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index 9cf6b7f44..15b600c54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -31,6 +32,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; @RestController +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index e45211b0b..77005a1e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -7,13 +7,15 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @RestController -@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda") +@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") +@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}") public class DispatchPdaController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private DispatchPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index c73e5fb3b..6c1285038 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -1,27 +1,15 @@ package com.baiyue.lastleg.dto; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; -import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; + +import java.time.LocalDateTime; import java.util.List; public record LastlegOrderDetailResponse( - LastlegOrder order, + LastlegOrderSummary order, List cargo, - List statusHistory, - List dropshipPallets + List statusHistory ) { - public record DropshipPallet( - Long profileId, - String sourcePalletNo, - Long warehousePalletId, - String warehousePalletNo, - Long inventoryPositionId, - String status, - List skuInventory, - List cartons - ) {} + public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 0ae6357f7..8cc4eeaaf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -26,9 +26,6 @@ public record LastlegOrderSummary( Boolean detained, Integer palletCount, Integer packageCount, - Integer pickedPackageCount, - Integer shippedPackageCount, - Integer remainingPackageCount, BigDecimal volumeCbm, Integer productQuantity, BigDecimal volumeWeightKg, @@ -36,8 +33,6 @@ public record LastlegOrderSummary( LocalDateTime actualArrivalAt, LocalDateTime estimatedWarehouseAt, String inboundOrderNo, - String outboundOrderNo, - String deliveryOrderNo, String relabelOrderNo, String relabelStatus, String palletMergeFlag, @@ -46,6 +41,7 @@ public record LastlegOrderSummary( String privateRemark, String customerRemark, String warehouseRemark, - LocalDateTime createdAt + LocalDateTime createdAt, + LocalDateTime updatedAt ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java index a1179f95d..032425af3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -23,6 +23,8 @@ public class LastlegEventOutbox { private Integer retryCount; private LocalDateTime nextRetryAt; private LocalDateTime publishedAt; + private LocalDateTime lockedAt; + private String lockedBy; private String lastError; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3e9cb5d66..20ebe6d42 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -1,20 +1,25 @@ package com.baiyue.lastleg.inboundplan.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.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -25,15 +30,27 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private InboundFbaAppointmentService appointmentService; + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Page inbound plans without PDA, internal-event or outbound workflow fields", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @Valid InboundPlanRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + @PostMapping("/convert") - @Operation(summary = "Convert one inbound order to one inbound plan") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @Operation(summary = "Convert one inbound order to one inbound plan", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); } @GetMapping("/{planNo}") - @Operation(summary = "Get an inbound plan with lines, pallets and allocations") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Get an inbound plan with lines, pallets and allocations", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @RequestParam(required = false) String customerId) { @@ -41,7 +58,8 @@ public class InboundPlanController { } @PostMapping("/{planNo}/pallets/supplement") - @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @Valid @RequestBody SupplementPalletRequest request) { @@ -49,7 +67,8 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") - @Operation(summary = "Create one FBA appointment for selected inbound lines") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "Create one FBA appointment for selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) public ApiResponse batchFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody BatchFbaAppointmentRequest request) { @@ -59,7 +78,8 @@ public class InboundPlanController { } @GetMapping("/fba-appointments/{appointmentNo}") - @Operation(summary = "Get an FBA appointment and its selected inbound lines") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "Get an FBA appointment and its selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse fbaAppointmentDetail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String appointmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java new file mode 100644 index 000000000..b7a4a5d11 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import java.time.LocalDateTime; + +public final class InboundPlanRequests { + private InboundPlanRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String planNo, + String inboundCode, + String status, + String overseasWarehouseId, + String destinationWarehouseCode, + String fbaWarehouseCode, + String appointmentStatus, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java new file mode 100644 index 000000000..e386e184c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded") +public record InboundPlanSummary( + Long id, + String planNo, + Long sourceOrderId, + String customerId, + String customerName, + String orderType, + String inboundCode, + String containerNo, + String overseasWarehouseId, + String overseasWarehouseName, + String destinationWarehouseId, + String destinationWarehouseCode, + String destinationWarehouseName, + String fbaWarehouseCode, + Integer packageCount, + BigDecimal totalVolumeCbm, + Integer palletCount, + String status, + String appointmentStatus, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index c8c7f0c6a..68bc44b2c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -1,13 +1,50 @@ package com.baiyue.lastleg.inboundplan.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import java.util.List; + @Mapper public interface InboundPlanMapper extends BaseMapper { + String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " + + "LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " + + "WHERE p.customer_id=#{customerId}" + + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND p.status=#{q.status}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + + " AND l.fba_warehouse_code=#{q.fbaWarehouseCode}" + + " " + + "AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " + + "WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})" + + " AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " + + "WHERE ai.plan_id=p.id)" + + " AND p.created_at >=#{q.createdFrom}" + + " AND p.created_at <=#{q.createdTo}"; + + @Select("") + List pageRows(@Param("customerId") String customerId, + @Param("q") InboundPlanRequests.Query query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query); + @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java index f024f8efd..eb33d0f29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java @@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @RestController -@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets") +@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets") public class InboundPalletAssignmentController { @Resource private InboundPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 9cc116ea1..0c11e1f3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; @RestController -@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda") +@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}") public class InboundPdaController { public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; @Resource private InboundPdaWorkflowService service; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java index 814967824..410985cdc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java @@ -20,6 +20,9 @@ public class InboundStockReceipt { private Long lineId; private Long orderId; private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; private Long warehousePalletId; private String palletNo; private Long warehouseLocationId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index a2ec49f0f..69fa42e4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -13,6 +14,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; @@ -34,6 +36,7 @@ import java.util.stream.Collectors; @Service public class InboundStockReceiptService { @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderDetailMapper orderDetails; @Resource private InboundStockReceiptMapper receipts; @Resource private InventoryCommandService inventory; @Resource private DropshipOrderService dropshipOrders; @@ -79,9 +82,22 @@ public class InboundStockReceiptService { if (pickedPackages != value(line.getRequiredPackageCount())) { throw conflict("PDA picked packages must equal the inbound order package total"); } + List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? List.of() : skuRemainders(order.getId(), pickedPackages); for (InboundPlanPalletAllocation allocation : lineAllocations) { - created.add(persist(customerId, plan, task, line, allocation, - requirePallet(palletById, allocation), order, receivedAt)); + InboundPlanPallet pallet = requirePallet(palletById, allocation); + if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); + } else { + long allocationPackages = value(allocation.getPickedPackageCount()); + for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt)); + } + } + } + if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) { + throw conflict("Inbound SKU package snapshots were not fully received"); } } activateDropship(customerId, task.getId(), orderById, created); @@ -112,20 +128,21 @@ public class InboundStockReceiptService { private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line, InboundPlanPalletAllocation allocation, InboundPlanPallet pallet, - LastlegOrder order, LocalDateTime receivedAt) { - long packages = value(allocation.getPickedPackageCount()); + LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) { + long packages = sku.packages(); if (packages <= 0 || pallet.getWarehousePalletId() == null) { throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); } String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), "Destination warehouse code is required for stock-in"); - String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId(); + String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(), new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId, null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode, line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(), + sku.skuCode(), sku.productNameZh(), sku.productNameEn(), pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(), pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(), order.getSourceEventId(), operationKey, @@ -140,6 +157,9 @@ public class InboundStockReceiptService { receipt.setLineId(line.getId()); receipt.setOrderId(order.getId()); receipt.setInboundCode(line.getInboundCode()); + receipt.setSkuCode(sku.skuCode()); + receipt.setProductNameZh(sku.productNameZh()); + receipt.setProductNameEn(sku.productNameEn()); receipt.setWarehousePalletId(pallet.getWarehousePalletId()); receipt.setPalletNo(pallet.getPalletNo()); receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); @@ -154,6 +174,46 @@ public class InboundStockReceiptService { return receipt; } + private List skuRemainders(Long orderId, long expectedPackages) { + List details = orderDetails.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId)); + Map grouped = new java.util.LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + long packages = value(detail.getPackageCount()); + if (skuCode == null || packages <= 0) continue; + SkuRemainder existing = grouped.get(skuCode); + if (existing == null) { + grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()), + trim(detail.getProductNameEn()), packages)); + } else { + existing.remaining += packages; + } + } + long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum(); + if (grouped.isEmpty() || actualPackages != expectedPackages) { + throw conflict("Inbound SKU package snapshots must exactly match the received package total"); + } + return new java.util.ArrayList<>(grouped.values()); + } + + private static List consume(List remainders, long packages) { + List result = new java.util.ArrayList<>(); + long pending = packages; + for (SkuRemainder remainder : remainders) { + if (pending == 0) break; + if (remainder.remaining == 0) continue; + long accepted = Math.min(pending, remainder.remaining); + result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh, + remainder.productNameEn, accepted)); + remainder.remaining -= accepted; + pending -= accepted; + } + if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots"); + return result; + } + private void activateDropship(String customerId, Long taskId, Map orderById, List stockReceipts) { Map> receiptsByOrder = stockReceipts.stream() @@ -190,4 +250,24 @@ public class InboundStockReceiptService { private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + + private static final class SkuRemainder { + private final String skuCode; + private final String productNameZh; + private final String productNameEn; + private long remaining; + + private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) { + this.skuCode = skuCode; + this.productNameZh = productNameZh; + this.productNameEn = productNameEn; + this.remaining = remaining; + } + } + + private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) { + private static SkuSnapshot unspecified(long packages) { + return new SkuSnapshot("UNSPECIFIED", null, null, packages); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java new file mode 100644 index 000000000..dcdaa2c9f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */ +@Component +@Slf4j +public class InboundUnloadCompletedOutboxRelay { + @Resource private UnloadCompletedOutboxRepository repository; + @Resource private EventBridgeClient client; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; + @Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}") + public void relay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10)); + for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { + if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); + if (row == null) continue; + try { + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + EventEnvelopeValidator.requireValid(event); + var response = client.receive(internalToken, event); + if (response == null || !response.successful() || response.data() == null + || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件"); + } + repository.published(row.getId(), workerId, LocalDateTime.now()); + } catch (Exception exception) { + repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), + exception.getMessage()); + log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 588cd634d..ac12ebc30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -2,7 +2,10 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; @@ -36,6 +39,19 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; + @Transactional(readOnly = true) + public PageResult page(String customerId, InboundPlanRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + } + long total = planMapper.countRows(customerId, query); + List records = total == 0 ? List.of() + : planMapper.pageRows(customerId, query, Math.multiplyExact(query.page() - 1, query.size()), query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { List orders = orderMapper.lockOrder(customerId, selectedOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 71d44c87b..7d7437819 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -5,12 +5,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,12 +23,13 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/inventory") @Tag(name = "Tail-leg inbound-order package inventory commands") public class InventoryCommandController { - public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; - public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; + public static final String MANAGE_PERMISSION = OverseasPermissions.INVENTORY_EDIT; + public static final String ADJUST_PERMISSION = OverseasPermissions.INVENTORY_ADJUST; @Resource private InventoryCommandService service; @PostMapping("/hold") - @Operation(summary = "Move available inbound-order packages to hold") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Move available inbound-order packages to hold", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); @@ -34,47 +37,17 @@ public class InventoryCommandController { } @PostMapping("/release") - @Operation(summary = "Release held inbound-order packages") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EDIT + "')") + @Operation(summary = "Release held inbound-order packages", description = "Required authority: " + OverseasPermissions.INVENTORY_EDIT) public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Move request) { requirePermission(user, MANAGE_PERMISSION); return ApiResponse.success(service.release(scope(user, request.customerId()), operator(user), request)); } - @PostMapping("/allocate") - @Operation(summary = "Allocate available inbound-order packages to an outbound order") - public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.allocate(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/unallocate") - @Operation(summary = "Cancel an outbound allocation") - public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.unallocate(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/pick") - @Operation(summary = "Confirm picked inbound-order packages") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.pick(scope(user, request.customerId()), operator(user), request)); - } - - @PostMapping("/ship") - @Operation(summary = "Ship picked inbound-order packages and report whether the pallet is empty") - public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody InventoryCommandRequests.Move request) { - requirePermission(user, MANAGE_PERMISSION); - return ApiResponse.success(service.ship(scope(user, request.customerId()), operator(user), request)); - } - @PostMapping("/adjust") - @Operation(summary = "Adjust inbound-order package inventory after a physical count") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_ADJUST + "')") + @Operation(summary = "Adjust inbound-order package inventory after a physical count", description = "Required authority: " + OverseasPermissions.INVENTORY_ADJUST) public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody InventoryCommandRequests.Adjustment request) { requirePermission(user, ADJUST_PERMISSION); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index e768ccb21..bd8bb57b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -22,6 +22,9 @@ public final class InventoryCommandRequests { @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, @NotBlank String inboundCode, + String skuCode, + String productNameZh, + String productNameEn, @NotNull Long palletId, @NotBlank String palletNo, String batchNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index e96f09a59..7d6edae29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -175,8 +175,9 @@ public class InventoryCommandService { } private InventoryBalance balance(String customerId, InventoryCommandRequests.StockIn request) { + String skuCode = sku(request.skuCode()); InventoryBalance balance = balances.lockDimension(customerId, request.overseasWarehouseId(), - request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode); if (balance != null) return balance; balance = new InventoryBalance(); balance.setId(IdWorker.getId()); @@ -191,6 +192,9 @@ public class InventoryCommandService { balance.setInboundOrderId(request.inboundOrderId()); balance.setInboundOrderNo(request.inboundOrderNo().trim()); balance.setInboundCode(request.inboundCode().trim()); + balance.setSkuCode(skuCode); + balance.setProductNameZh(trim(request.productNameZh())); + balance.setProductNameEn(trim(request.productNameEn())); zero(balance); balance.setVersion(0L); balance.setCreatedAt(LocalDateTime.now()); @@ -200,7 +204,7 @@ public class InventoryCommandService { return balance; } catch (DuplicateKeyException exception) { InventoryBalance concurrent = balances.lockDimension(customerId, request.overseasWarehouseId(), - request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode()); + request.destinationWarehouseCode(), request.inboundOrderId(), request.inboundCode(), skuCode); if (concurrent != null) return concurrent; throw exception; } @@ -314,6 +318,7 @@ public class InventoryCommandService { ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setSkuCode(balance.getSkuCode()); ledger.setBusinessType(businessType); ledger.setBusinessOrderId(balance.getInboundOrderId()); ledger.setBusinessOrderNo(trim(businessOrderNo)); @@ -434,6 +439,11 @@ public class InventoryCommandService { return value == null || value.isBlank() ? null : value.trim(); } + private static String sku(String value) { + String normalized = trim(value); + return normalized == null ? "UNSPECIFIED" : normalized; + } + private static String remarkSuffix(String remark) { String value = trim(remark); return value == null ? "" : "; " + value; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java index c7e62a204..e2468fcaa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java @@ -24,6 +24,9 @@ public class InventoryBalance { private Long inboundOrderId; private String inboundOrderNo; private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; private Long availablePackages; private Long holdPackages; private Long allocatedPackages; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..16cd696b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -12,22 +12,24 @@ public interface InventoryBalanceMapper extends BaseMapper { @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + "AND overseas_warehouse_id=#{overseasWarehouseId} " + "AND destination_warehouse_code=#{destinationWarehouseCode} " + - "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1") + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1") InventoryBalance findDimension(@Param("customerId") String customerId, @Param("overseasWarehouseId") String overseasWarehouseId, @Param("destinationWarehouseCode") String destinationWarehouseCode, @Param("inboundOrderId") Long inboundOrderId, - @Param("inboundCode") String inboundCode); + @Param("inboundCode") String inboundCode, + @Param("skuCode") String skuCode); @Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} " + "AND overseas_warehouse_id=#{overseasWarehouseId} " + "AND destination_warehouse_code=#{destinationWarehouseCode} " + - "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} LIMIT 1 FOR UPDATE") + "AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE") InventoryBalance lockDimension(@Param("customerId") String customerId, @Param("overseasWarehouseId") String overseasWarehouseId, @Param("destinationWarehouseCode") String destinationWarehouseCode, @Param("inboundOrderId") Long inboundOrderId, - @Param("inboundCode") String inboundCode); + @Param("inboundCode") String inboundCode, + @Param("skuCode") String skuCode); @Select("") + List customerOptions(@Param("keyword") String keyword, @Param("limit") int limit); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java deleted file mode 100644 index 3f96d9478..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOutboundMapper.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.procurement.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.util.List; - -@Mapper -public interface OverseasProcurementOutboundMapper extends BaseMapper { - @Select("SELECT * FROM ll_overseas_procurement_outbound WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY outbound_at DESC,id DESC") - List listOrder(@Param("customerId") String customerId, - @Param("orderId") Long orderId); -} 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 d955c88d8..865caf78c 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 @@ -4,15 +4,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; -import com.baiyue.lastleg.procurement.entity.OverseasProcurementOutbound; import com.baiyue.lastleg.procurement.entity.OverseasProcurementStatusHistory; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; @@ -26,21 +27,26 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.HexFormat; +import java.util.Objects; import java.util.Set; @Service public class OverseasProcurementService { - public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; - public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; + public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; + public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; private static final Set STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED); @Resource private OverseasProcurementOrderMapper orders; - @Resource private OverseasProcurementOutboundMapper outbounds; @Resource private OverseasProcurementStatusHistoryMapper histories; + @Resource private PartnerWarehouseDirectory warehouseDirectory; @Resource private ObjectMapper objectMapper; @Transactional @@ -68,7 +74,6 @@ public class OverseasProcurementService { order.setContactName(request.contactName().trim()); order.setContactPhone(request.contactPhone().trim()); order.setInboundQuantity(0L); - order.setOutboundQuantity(0L); order.setStatus(PENDING_REVIEW); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); @@ -84,10 +89,25 @@ public class OverseasProcurementService { if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } - history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, null, request.remark(), now); return detail(customerId, order.getOrderNo()); } + public List customerOptions(String keyword, int limit) { + if (limit < 1 || limit > 200) throw badRequest("limit must be between 1 and 200"); + return orders.customerOptions(trim(keyword), limit).stream() + .map(value -> new OverseasProcurementResponses.CustomerOption(value.getCustomerId(), + Objects.requireNonNullElse(trim(value.getCustomerName()), value.getCustomerId()))) + .toList(); + } + + public List warehouseOptions() { + return warehouseDirectory.options().stream() + .map(value -> new OverseasProcurementResponses.WarehouseOption(value.warehouseId(), + value.warehouseCode(), value.warehouseName(), value.countryCode())) + .toList(); + } + public PageResult page( String customerId, OverseasProcurementRequests.Query query) { validateQuery(query); @@ -122,10 +142,10 @@ public class OverseasProcurementService { order.getDomesticCarrier(), order.getDomesticTrackingNo(), order.getOverseasCarrier(), order.getOverseasTrackingNo(), order.getEstimatedArrivalAt(), order.getRemark(), order.getCreatedBy(), order.getReviewedBy(), order.getReviewRemark(), order.getReviewedAt(), - outbounds.listOrder(customerId, order.getId()).stream().map(this::outbound).toList(), histories.listOrder(customerId, order.getId()).stream().map(value -> - new OverseasProcurementResponses.History(value.getFromStatus(), value.getToStatus(), - value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList()); + new OverseasProcurementResponses.History(value.getOperationType(), value.getFromStatus(), + value.getToStatus(), value.getOperatorId(), value.getChangeDetail(), value.getRemark(), + value.getCreatedAt())).toList()); } @Transactional @@ -146,64 +166,38 @@ public class OverseasProcurementService { order.setReviewedAt(now); touch(order, now); orders.updateById(order); - history(order, PENDING_REVIEW, next, operatorId, request.remark(), now); + history(order, "REVIEW", PENDING_REVIEW, next, operatorId, null, request.remark(), now); return detail(customerId, orderNo); } @Transactional - public OverseasProcurementResponses.Detail update(String customerId, String orderNo, + public OverseasProcurementResponses.Detail update(String customerId, String orderNo, String operatorId, OverseasProcurementRequests.Update request) { OverseasProcurementOrder order = lock(customerId, orderNo); if (!request.version().equals(order.getVersion())) throw conflict("Order was changed; refresh and retry"); + Map before = editableSnapshot(order); if (request.inboundQuantity() != null) { if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can receive stock"); - if (request.inboundQuantity() < value(order.getOutboundQuantity())) { - throw conflict("Inbound quantity cannot be lower than the recorded outbound quantity"); - } order.setInboundQuantity(request.inboundQuantity()); } setIfText(request.purchaseCategory(), order::setPurchaseCategory); setIfText(request.productName(), order::setProductName); - setIfPresent(request.productSpecification(), order::setProductSpecification); - setIfPresent(request.productUrl(), order::setProductUrl); - setIfPresent(request.purchaseWarehouseId(), order::setPurchaseWarehouseId); - setIfPresent(request.purchaseWarehouseName(), order::setPurchaseWarehouseName); - setIfPresent(request.domesticCarrier(), order::setDomesticCarrier); - setIfPresent(request.domesticTrackingNo(), order::setDomesticTrackingNo); - setIfPresent(request.overseasCarrier(), order::setOverseasCarrier); - setIfPresent(request.overseasTrackingNo(), order::setOverseasTrackingNo); - if (request.estimatedArrivalAt() != null) order.setEstimatedArrivalAt(request.estimatedArrivalAt()); - setIfPresent(request.remark(), order::setRemark); - touch(order, LocalDateTime.now()); + patchText(request.productSpecification(), 500, "productSpecification", order::setProductSpecification); + patchText(request.productUrl(), 2000, "productUrl", order::setProductUrl); + patchWarehouse(order, request.purchaseWarehouseId(), request.purchaseWarehouseName()); + patchText(request.domesticCarrier(), 128, "domesticCarrier", order::setDomesticCarrier); + patchText(request.domesticTrackingNo(), 128, "domesticTrackingNo", order::setDomesticTrackingNo); + patchText(request.overseasCarrier(), 128, "overseasCarrier", order::setOverseasCarrier); + patchText(request.overseasTrackingNo(), 128, "overseasTrackingNo", order::setOverseasTrackingNo); + patchDateTime(request.estimatedArrivalAt(), order::setEstimatedArrivalAt); + patchText(request.remark(), 1000, "remark", order::setRemark); + Map after = editableSnapshot(order); + if (before.equals(after)) return detail(customerId, orderNo); + LocalDateTime now = LocalDateTime.now(); + touch(order, now); orders.updateById(order); - return detail(customerId, orderNo); - } - - @Transactional - public OverseasProcurementResponses.Detail addOutbound(String customerId, String orderNo, String operatorId, - OverseasProcurementRequests.AddOutbound request) { - OverseasProcurementOrder order = lock(customerId, orderNo); - if (!APPROVED.equals(order.getStatus())) throw conflict("Only an approved procurement order can ship stock"); - if (orders.addOutbound(order.getId(), customerId, request.quantity()) != 1) { - throw conflict("Outbound quantity exceeds current procurement inventory"); - } - OverseasProcurementOutbound outbound = new OverseasProcurementOutbound(); - outbound.setId(IdWorker.getId()); - outbound.setOrderId(order.getId()); - outbound.setCustomerId(customerId); - outbound.setQuantity(request.quantity()); - outbound.setOutboundNo(request.outboundNo().trim()); - outbound.setCarrier(trim(request.carrier())); - outbound.setTrackingNo(trim(request.trackingNo())); - outbound.setOutboundAt(request.outboundAt() == null ? LocalDateTime.now() : request.outboundAt()); - outbound.setRemark(trim(request.remark())); - outbound.setCreatedBy(operatorId); - outbound.setCreatedAt(LocalDateTime.now()); - try { - outbounds.insert(outbound); - } catch (DuplicateKeyException exception) { - throw conflict("Outbound number already exists for this procurement order"); - } + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, + changeDetail(before, after), null, now); return detail(customerId, orderNo); } @@ -222,29 +216,24 @@ public class OverseasProcurementService { private OverseasProcurementResponses.Summary summary(OverseasProcurementOrder order) { long inbound = value(order.getInboundQuantity()); - long outbound = value(order.getOutboundQuantity()); return new OverseasProcurementResponses.Summary(order.getId(), order.getOrderNo(), order.getCustomerId(), order.getCustomerName(), order.getPurchaseCategory(), order.getProductName(), order.getProductSpecification(), value(order.getPurchaseQuantity()), order.getBudgetUnitPrice(), - order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, outbound, - inbound - outbound, order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); + order.getCurrency(), order.getPurchaseWarehouseId(), order.getPurchaseWarehouseName(), inbound, inbound, + order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt()); } - private OverseasProcurementResponses.Outbound outbound(OverseasProcurementOutbound value) { - return new OverseasProcurementResponses.Outbound(value.getId(), value(value.getQuantity()), value.getOutboundNo(), - value.getCarrier(), value.getTrackingNo(), value.getOutboundAt(), value.getRemark(), value.getCreatedBy(), - value.getCreatedAt()); - } - - private void history(OverseasProcurementOrder order, String from, String to, String operator, - String remark, LocalDateTime now) { + private void history(OverseasProcurementOrder order, String operationType, String from, String to, + String operator, String changeDetail, String remark, LocalDateTime now) { OverseasProcurementStatusHistory history = new OverseasProcurementStatusHistory(); history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId()); + history.setOperationType(operationType); history.setFromStatus(from); history.setToStatus(to); history.setOperatorId(operator); + history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); histories.insert(history); @@ -267,6 +256,91 @@ public class OverseasProcurementService { } } + private void patchWarehouse(OverseasProcurementOrder order, JsonNode warehouseId, JsonNode warehouseName) { + if (warehouseId == null) { + if (warehouseName != null) throw badRequest("purchaseWarehouseName cannot be changed without purchaseWarehouseId"); + return; + } + String id = nullableText(warehouseId, 128, "purchaseWarehouseId"); + if (id == null) { + order.setPurchaseWarehouseId(null); + order.setPurchaseWarehouseName(null); + return; + } + long numericId; + try { + numericId = Long.parseLong(id); + } catch (NumberFormatException exception) { + throw badRequest("purchaseWarehouseId must be a numeric overseas partner warehouse ID"); + } + var option = warehouseDirectory.required(numericId); + order.setPurchaseWarehouseId(option.warehouseId()); + order.setPurchaseWarehouseName(option.warehouseName()); + } + + private static void patchText(JsonNode node, int maxLength, String field, + java.util.function.Consumer setter) { + if (node != null) setter.accept(nullableText(node, maxLength, field)); + } + + private static String nullableText(JsonNode node, int maxLength, String field) { + if (node.isNull()) return null; + if (!node.isTextual()) throw badRequest(field + " must be a string or null"); + String value = trim(node.textValue()); + if (value != null && value.length() > maxLength) throw badRequest(field + " exceeds max length " + maxLength); + return value; + } + + private static void patchDateTime(JsonNode node, java.util.function.Consumer setter) { + if (node == null) return; + String value = nullableText(node, 64, "estimatedArrivalAt"); + if (value == null) { + setter.accept(null); + return; + } + try { + setter.accept(LocalDateTime.parse(value)); + } catch (DateTimeParseException exception) { + throw badRequest("estimatedArrivalAt must be an ISO local date-time or null"); + } + } + + private static Map editableSnapshot(OverseasProcurementOrder order) { + Map result = new LinkedHashMap<>(); + result.put("purchaseCategory", order.getPurchaseCategory()); + result.put("productName", order.getProductName()); + result.put("productSpecification", order.getProductSpecification()); + result.put("productUrl", order.getProductUrl()); + result.put("inboundQuantity", value(order.getInboundQuantity())); + result.put("purchaseWarehouseId", order.getPurchaseWarehouseId()); + result.put("purchaseWarehouseName", order.getPurchaseWarehouseName()); + result.put("domesticCarrier", order.getDomesticCarrier()); + result.put("domesticTrackingNo", order.getDomesticTrackingNo()); + result.put("overseasCarrier", order.getOverseasCarrier()); + result.put("overseasTrackingNo", order.getOverseasTrackingNo()); + result.put("estimatedArrivalAt", order.getEstimatedArrivalAt()); + result.put("remark", order.getRemark()); + return result; + } + + private String changeDetail(Map before, Map after) { + Map changes = new LinkedHashMap<>(); + before.forEach((field, oldValue) -> { + Object newValue = after.get(field); + if (!Objects.equals(oldValue, newValue)) { + Map pair = new LinkedHashMap<>(); + pair.put("before", oldValue); + pair.put("after", newValue); + changes.put(field, pair); + } + }); + try { + return objectMapper.writeValueAsString(changes); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize procurement change detail", exception); + } + } + private static void touch(OverseasProcurementOrder order, LocalDateTime now) { order.setVersion(value(order.getVersion()) + 1); order.setUpdatedAt(now); @@ -278,9 +352,6 @@ public class OverseasProcurementService { private static void setIfText(String value, java.util.function.Consumer setter) { if (StringUtils.hasText(value)) setter.accept(value.trim()); } - private static void setIfPresent(String value, java.util.function.Consumer setter) { - if (value != null) setter.accept(trim(value)); - } private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 349c89f69..c4c69538e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.dto.RelabelResponses; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,21 +22,29 @@ public class RelabelApplicationController { @Resource private RelabelApplicationService service; @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_CREATE + "')") + @Operation(summary = "Create a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_CREATE) public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Page relabel applications", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + @Operation(summary = "Get a relabel application", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); } @PutMapping("/{relabelNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_STATUS + "')") + @Operation(summary = "Change relabel application status", description = "Required authority: " + OverseasPermissions.RELABEL_ORDER_STATUS) public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.ChangeStatus request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java new file mode 100644 index 000000000..72fd13cd9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class UnloadCompletedOutboxRepository { + @Resource private LastlegEventOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .orderByAsc(LastlegEventOutbox::getCreatedAt).orderByAsc(LastlegEventOutbox::getId) + .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); + } + + public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker) + .set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public LastlegEventOutbox claimed(long id, String worker) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker)); + } + + public void published(long id, String worker, LocalDateTime now) { + mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "PUBLISHED") + .set(LastlegEventOutbox::getPublishedAt, now) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, null)); + } + + public void retry(long id, String worker, LocalDateTime retryAt, String error) { + mapper.update(null, claimedBase(id, worker) + .setSql("retry_count = retry_count + 1") + .set(LastlegEventOutbox::getStatus, "PENDING") + .set(LastlegEventOutbox::getNextRetryAt, retryAt) + .set(LastlegEventOutbox::getLockedBy, null) + .set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } + + private LambdaUpdateWrapper claimedBase(long id, String worker) { + return new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id) + .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING") + .eq(LastlegEventOutbox::getLockedBy, worker); + } + + private static String truncate(String value) { + String safe = value == null || value.isBlank() ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " "); + return safe.length() > 500 ? safe.substring(0, 500) : safe; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java new file mode 100644 index 000000000..910f94a98 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.security; + +/** Canonical Admin authorities for fourth-phase overseas APIs. */ +public final class OverseasPermissions { + public static final String LAST_MILE_ORDER_LIST = "platform:overseas-warehouse:last-mile-order:list"; + public static final String LAST_MILE_ORDER_EDIT = "platform:overseas-warehouse:last-mile-order:edit"; + public static final String LAST_MILE_ORDER_EXPORT = "platform:overseas-warehouse:last-mile-order:export"; + public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; + public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; + public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; + public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; + public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; + public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; + public static final String INVENTORY_LIST = "platform:overseas-warehouse:inventory:list"; + public static final String INVENTORY_EXPORT = "platform:overseas-warehouse:inventory:export"; + public static final String INVENTORY_EDIT = "platform:overseas-warehouse:inventory:edit"; + public static final String INVENTORY_ADJUST = "platform:overseas-warehouse:inventory:adjust"; + public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list"; + public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit"; + public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review"; + public static final String TRUCK_LIST = "platform:overseas-business:truck:list"; + public static final String TRUCK_CREATE = "platform:overseas-business:truck:create"; + public static final String TRUCK_EDIT = "platform:overseas-business:truck:edit"; + public static final String TRUCK_STATUS = "platform:overseas-business:truck:status"; + public static final String LOOSEBOARD_LIST = "platform:overseas-business:looseboard:list"; + public static final String LOOSEBOARD_CREATE = "platform:overseas-business:looseboard:create"; + public static final String LOOSEBOARD_EDIT = "platform:overseas-business:looseboard:edit"; + public static final String LOOSEBOARD_STATUS = "platform:overseas-business:looseboard:status"; + public static final String QUOTE_LIST = "platform:overseas-master-data:quote:list"; + public static final String QUOTE_CREATE = "platform:overseas-master-data:quote:create"; + public static final String QUOTE_EDIT = "platform:overseas-master-data:quote:edit"; + public static final String QUOTE_STATUS = "platform:overseas-master-data:quote:status"; + public static final String QUOTE_EXPORT = "platform:overseas-master-data:quote:export"; + public static final String EXCHANGE_RATE_LIST = "platform:overseas-master-data:exchange-rate:list"; + public static final String EXCHANGE_RATE_CREATE = "platform:overseas-master-data:exchange-rate:create"; + public static final String EXCHANGE_RATE_EDIT = "platform:overseas-master-data:exchange-rate:edit"; + public static final String EXCHANGE_RATE_STATUS = "platform:overseas-master-data:exchange-rate:status"; + public static final String EXCHANGE_RATE_EXPORT = "platform:overseas-master-data:exchange-rate:export"; + public static final String VEHICLE_LIST = "platform:overseas-master-data:vehicle:list"; + public static final String VEHICLE_CREATE = "platform:overseas-master-data:vehicle:create"; + public static final String VEHICLE_EDIT = "platform:overseas-master-data:vehicle:edit"; + public static final String VEHICLE_STATUS = "platform:overseas-master-data:vehicle:status"; + public static final String VEHICLE_EXPORT = "platform:overseas-master-data:vehicle:export"; + + private OverseasPermissions() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 414d2067f..d84c1c506 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -6,11 +6,6 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; -import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -29,8 +24,6 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Map; -import java.util.stream.Collectors; @Service public class LastlegOrderService { @@ -41,9 +34,6 @@ public class LastlegOrderService { @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; - @Resource private DropshipOrderProfileMapper dropshipProfiles; - @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; - @Resource private DropshipCartonMapper dropshipCartons; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -60,23 +50,9 @@ public class LastlegOrderService { new LambdaQueryWrapper() .eq(LastlegOrderStatusHistory::getOrderId, orderId) .orderByAsc(LastlegOrderStatusHistory::getCreatedAt)); - List pallets = dropshipPallets(customerId, order); - return new LastlegOrderDetailResponse(order, cargo, history, pallets); - } - - private List dropshipPallets(String customerId, LastlegOrder order) { - if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) return List.of(); - var profiles = dropshipProfiles.listOrder(customerId, order.getId()); - if (profiles.isEmpty()) return List.of(); - Map> skuByProfile = dropshipSkuInventory - .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); - Map> cartonsByProfile = dropshipCartons - .listOrder(customerId, order.getId()).stream().collect(Collectors.groupingBy(DropshipCarton::getProfileId)); - return profiles.stream().map(profile -> new LastlegOrderDetailResponse.DropshipPallet( - profile.getId(), profile.getSourcePalletNo(), profile.getWarehousePalletId(), profile.getPalletNo(), - profile.getInventoryPositionId(), profile.getStatus(), - skuByProfile.getOrDefault(profile.getId(), List.of()), - cartonsByProfile.getOrDefault(profile.getId(), List.of()))).toList(); + return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() + .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), + value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList()); } @Transactional @@ -126,20 +102,16 @@ public class LastlegOrderService { List orders = orderMapper.selectList(filter(customerId, query) .orderByDesc(LastlegOrder::getCreatedAt).last("LIMIT 10000")); List lines = new ArrayList<>(); - lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,已拣箱数,已出库箱数,剩余箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,出库单号,派送单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); + lines.add("ID,尾程单号,订单类型,入仓码,头程单号,货主,海外仓,转仓,转仓编码,FBA仓库,集装箱号,货柜状态,是否异常,是否暂扣,托数,箱数,体积(m3),数量,体积重量,预计到港时间,实际到港时间,预计到仓,入库单号,换标单号,换标状态,合托标记,主流程状态,子单状态,备注(仅自己),备注(客户),备注(仓库)"); for (LastlegOrder order : orders) { lines.add(String.join(",", List.of( csv(order.getId()), csv(order.getOrderNo()), csv(order.getOrderType()), csv(order.getInboundCode()), csv(order.getSourceOrderNo()), csv(order.getCustomerName()), csv(order.getOverseasWarehouseName()), csv(order.getTransferWarehouseName()), csv(order.getTransferWarehouseCode()), csv(order.getFbaWarehouseCode()), csv(order.getContainerNo()), csv(order.getContainerStatus()), csv(order.getAbnormal()), csv(order.getDetained()), - csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getPickedPackageCount()), - csv(order.getShippedPackageCount()), csv(Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) - - Objects.requireNonNullElse(order.getShippedPackageCount(), 0))), - csv(order.getVolumeCbm()), csv(order.getProductQuantity()), + csv(order.getPalletCount()), csv(order.getPackageCount()), csv(order.getVolumeCbm()), csv(order.getProductQuantity()), csv(order.getVolumeWeightKg()), csv(order.getEstimatedArrivalAt()), csv(order.getActualArrivalAt()), - csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getOutboundOrderNo()), - csv(order.getDeliveryOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), + csv(order.getEstimatedWarehouseAt()), csv(order.getInboundOrderNo()), csv(order.getRelabelOrderNo()), csv(order.getRelabelStatus()), csv(order.getPalletMergeFlag()), csv(order.getCurrentStatus()), csv(order.getSubOrderStatus()), csv(order.getPrivateRemark()), csv(order.getCustomerRemark()), csv(order.getWarehouseRemark())))); } @@ -184,13 +156,11 @@ public class LastlegOrderService { o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), - o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getPickedPackageCount(), - o.getShippedPackageCount(), Math.max(0, Objects.requireNonNullElse(o.getPackageCount(), 0) - - Objects.requireNonNullElse(o.getShippedPackageCount(), 0)), o.getVolumeCbm(), + o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), - o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getOutboundOrderNo(), o.getDeliveryOrderNo(), - o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), - o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt()); + o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), + o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } private static String csv(Object value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 5abcb23ce..fa03464e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -394,12 +394,17 @@ public class WarehouseTransferService { if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt"); String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId(); + InventoryBalance sourceBalance = inventoryCommands.lockBalance(customerId, allocation.getInventoryBalanceId()); + if (sourceBalance == null || !customerId.equals(sourceBalance.getCustomerId())) { + throw conflict("Source inventory balance is missing for warehouse transfer receipt"); + } InventoryCommandResponse result = inventory.stockIn(customerId, operatorId, new InventoryCommandRequests.StockIn(customerId, null, request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()), trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()), request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo, + sourceBalance.getSkuCode(), sourceBalance.getProductNameZh(), sourceBalance.getProductNameEn(), request.destinationPalletId(), request.destinationPalletNo().trim(), trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(), trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java index 2c30b5907..2435b98c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java @@ -45,7 +45,7 @@ public class MerchantUsTruckOrderController { @PatchMapping("/{orderNo}") public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) { - return ApiResponse.success(service.update(customer(user), orderNo, request)); + return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index 3dba2f252..b1d1b1803 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -6,10 +6,13 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.service.UsTruckOrderService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,32 +29,59 @@ public class PlatformUsTruckOrderController { @Resource private UsTruckOrderService service; @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Page local truck orders", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.Query query) { requirePlatform(user); return ApiResponse.success(service.page(null, query)); } + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_CREATE + "')") + @Operation(summary = "Create a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_CREATE) + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @Valid @RequestBody UsTruckOrderRequests.Create request) { + requirePermission(user, OverseasPermissions.TRUCK_CREATE); + return ApiResponse.success(service.create(requiredCustomer(customerId), operator(user), request)); + } + @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_LIST + "')") + @Operation(summary = "Get a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_LIST) public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); return ApiResponse.success(service.detail(requiredCustomer(customerId), orderNo)); } + @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_EDIT + "')") + @Operation(summary = "Update a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_EDIT) + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Update request) { + requirePermission(user, OverseasPermissions.TRUCK_EDIT); + return ApiResponse.success(service.update(requiredCustomer(customerId), orderNo, operator(user), request)); + } + @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Change local truck order status", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { - requirePermission(user); + requirePermission(user, OverseasPermissions.TRUCK_STATUS); return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); } @PostMapping("/{orderNo}/receive-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { - requirePermission(user); + requirePermission(user, OverseasPermissions.TRUCK_STATUS); return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } @@ -62,11 +92,10 @@ public class PlatformUsTruckOrderController { } } - private void requirePermission(AuthenticatedUser user) { + private void requirePermission(AuthenticatedUser user, String permission) { requirePlatform(user); - if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, - UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + if (user.permissions() == null || !user.permissions().contains(permission)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, permission + " permission is required"); } } 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 4c6453f2d..4dd11a600 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 @@ -12,6 +12,7 @@ import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; +import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -35,7 +36,7 @@ import java.util.function.Consumer; @Service public class UsTruckOrderService { - public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; /** Kept for source compatibility; new orders use PENDING_REVIEW. */ @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; @@ -133,7 +134,8 @@ public class UsTruckOrderService { } @Transactional - public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) { + public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Update request) { UsTruckOrder order = lock(customerId, orderNo); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); @@ -147,7 +149,9 @@ public class UsTruckOrderService { if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); - touch(order); orders.updateById(order); return detail(customerId, orderNo); + touch(order); orders.updateById(order); + history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now()); + return detail(customerId, orderNo); } @Transactional diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 250cdcb8d..dc6c7d504 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -18,7 +18,7 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: @@ -35,6 +35,16 @@ spring: management.endpoints.web.exposure.include: health,info,prometheus baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL:} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} + firstleg: + event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} + outbox-relay: + fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} + lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} + retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8e3682ab8..116171234 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -519,11 +519,82 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox ( event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者', last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; +CREATE TABLE IF NOT EXISTS ll_overseas_quote ( + id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称', + warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照', + warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照', + provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种', + effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期', + version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code), + KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end) +) ENGINE=InnoDB COMMENT='海外仓报价主表'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee ( + id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位', + unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', + remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no), + KEY idx_ll_quote_fee_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价仓库费用'; + +CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price ( + id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序', + fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式', + estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额', + minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注', + PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no), + KEY idx_ll_quote_transit_quote (quote_id) +) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate ( + id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM', + base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), + KEY idx_ll_exchange_rate_page (status,rate_month) +) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率'; + +CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ( + id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID', + before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率', + before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) +) ENGINE=InnoDB COMMENT='月度汇率变更历史'; + +CREATE TABLE IF NOT EXISTS ll_vehicle ( + id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号', + model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升', + mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位', + phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), + KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) +) ENGINE=InnoDB COMMENT='平台车辆主数据'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''投递租约获取时间'' AFTER published_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''投递租约持有者'' AFTER locked_at')); +PREPARE ll_outbox_lease_stmt FROM @ddl; EXECUTE ll_outbox_lease_stmt; DEALLOCATE PREPARE ll_outbox_lease_stmt; + CREATE TABLE IF NOT EXISTS ll_inbound_pda_operation ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NULL COMMENT '分配记录ID', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -604,6 +675,9 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_order_no VARCHAR(128) NULL COMMENT '入仓单号快照', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码;无SKU的专项库存使用UNSPECIFIED', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', available_packages BIGINT NOT NULL DEFAULT 0 COMMENT '可用箱数', hold_packages BIGINT NOT NULL DEFAULT 0 COMMENT '冻结箱数', allocated_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已分配箱数', picked_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已拣箱数', in_transit_packages BIGINT NOT NULL DEFAULT 0 COMMENT '在途箱数', receiving_packages BIGINT NOT NULL DEFAULT 0 COMMENT '收货处理中箱数', @@ -612,10 +686,11 @@ CREATE TABLE IF NOT EXISTS ll_inventory_balance ( 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_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code), + UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code), KEY idx_ll_inventory_list (customer_id,overseas_warehouse_id,updated_at), - KEY idx_ll_inventory_inbound (customer_id,inbound_code) -) ENGINE=InnoDB COMMENT='入仓单维度实时库存余额'; + KEY idx_ll_inventory_inbound (customer_id,inbound_code), + KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at) +) ENGINE=InnoDB COMMENT='入仓单与SKU维度实时库存余额'; CREATE TABLE IF NOT EXISTS ll_inventory_position ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', @@ -669,13 +744,15 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', allocation_id BIGINT NOT NULL COMMENT '分配记录ID', line_id BIGINT NOT NULL COMMENT '入仓计划明细ID', order_id BIGINT NOT NULL COMMENT '订单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照', product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + product_name_en VARCHAR(255) NULL COMMENT '英文品名快照', warehouse_pallet_id BIGINT NOT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(64) NOT NULL COMMENT '托盘号', warehouse_location_id BIGINT NULL COMMENT '仓库库位ID', warehouse_location_code VARCHAR(128) NULL COMMENT '仓库库位编码', batch_no VARCHAR(128) NOT NULL COMMENT '库存批次号', received_packages BIGINT NOT NULL COMMENT '实收箱数', inventory_balance_id BIGINT NOT NULL COMMENT '库存余额记录ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID', inventory_operation_key VARCHAR(255) NOT NULL COMMENT '库存操作幂等键', received_at DATETIME(3) NOT NULL COMMENT '实收时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id), + PRIMARY KEY (id), UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code), KEY idx_ll_stock_receipt_plan (customer_id,plan_id,line_id), KEY idx_ll_stock_receipt_inventory (inventory_balance_id,inventory_position_id) ) ENGINE=InnoDB COMMENT='入仓PDA库存实收快照'; @@ -1080,7 +1157,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址', contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话', purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称', - inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计入库数量', outbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计出库数量', + inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商', domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商', overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', @@ -1095,22 +1172,14 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; -CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound ( - id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', - quantity BIGINT NOT NULL COMMENT '数量', outbound_no VARCHAR(128) NOT NULL COMMENT '出库单号', carrier VARCHAR(128) NULL COMMENT '承运商', - tracking_no VARCHAR(128) NULL COMMENT '物流跟踪号', outbound_at DATETIME(3) NOT NULL COMMENT '出库时间', - remark VARCHAR(1000) 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_overseas_procurement_outbound_no(customer_id,order_id,outbound_no), - KEY idx_ll_overseas_procurement_outbound_order(customer_id,order_id,outbound_at) -) ENGINE=InnoDB COMMENT='海外采购出库记录'; - 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', - remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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='海外采购审核历史'; +) ENGINE=InnoDB COMMENT='海外采购操作流水'; CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', @@ -1183,3 +1252,34 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( PRIMARY KEY(id), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; + +CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', + pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', + destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', + container_type VARCHAR(32) NULL COMMENT '柜型', package_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 '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', + contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', + requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' 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', + 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 '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), + UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), + KEY idx_ll_local_looseboard_list(customer_id,status,created_at), + KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) +) ENGINE=InnoDB COMMENT='本土散板订单'; + +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 '操作备注', + 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='本土散板操作流水'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml new file mode 100644 index 000000000..2d1d28902 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -0,0 +1,312 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Business API + version: 1.0.0 +paths: + /api/platform/v1/last-leg/overseas-procurements: + get: + summary: 分页查询海外采购订单 + description: 权限 platform:overseas-business:purchase:list;customerId 为空时查询全部客户。 + security: [{ bearerAuth: [] }] + parameters: + - { name: customerId, in: query, schema: { type: string } } + - { name: page, in: query, required: true, schema: { type: integer, minimum: 1 } } + - { name: size, in: query, required: true, schema: { type: integer, minimum: 1, maximum: 200 } } + - { name: orderNo, in: query, schema: { type: string } } + - { name: customerKeyword, in: query, schema: { type: string } } + - { name: purchaseCategory, in: query, schema: { type: string } } + - { name: productName, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ProcurementStatus' } } + - { name: purchaseWarehouseId, in: query, schema: { type: string } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/platform/v1/last-leg/overseas-procurements/customer-options: + get: + summary: 查询已有采购订单的客户候选 + description: 权限 platform:overseas-business:purchase:list;候选来自尾程库采购订单客户快照,不跨库查询 IAM。 + security: [{ bearerAuth: [] }] + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } + responses: + '200': + description: 客户候选 + content: { application/json: { schema: { $ref: '#/components/schemas/CustomerOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/warehouse-options: + get: + summary: 查询启用的海外合作采购仓候选 + description: 权限 platform:overseas-business:purchase:list;复用服务端缓存目录,浏览器不访问国内内部 API。 + security: [{ bearerAuth: [] }] + responses: + '200': + description: 采购仓候选 + content: { application/json: { schema: { $ref: '#/components/schemas/WarehouseOptionResponse' } } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询海外采购订单详情及操作流水 + description: 权限 platform:overseas-business:purchase:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + patch: + summary: 编辑海外采购订单 + description: 权限 platform:overseas-business:purchase:edit;可选字段缺失表示不修改,显式 null 或空串表示清空。采购仓名称由 warehouse ID 对应候选快照决定。采购出库不属于本 API。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementUpdate' } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 }, '409': { description: 乐观锁冲突或状态不允许 } } + /api/platform/v1/last-leg/overseas-procurements/{orderNo}/review: + post: + summary: 审核海外采购订单 + description: 权限 platform:overseas-business:purchase:review。 + 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 } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/merchant/v1/last-leg/overseas-procurements: + post: + summary: 客户创建海外采购订单 + description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + get: + summary: 客户分页查询自己的海外采购订单 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } } + /api/merchant/v1/last-leg/overseas-procurements/{orderNo}: + get: + summary: 客户查询自己的海外采购订单详情及流水 + security: [{ bearerAuth: [] }] + parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } } + /api/platform/v1/last-leg/us-truck-orders: + get: + summary: 分页查询本土卡车订单 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单分页 } } + post: + summary: 平台为指定客户创建本土卡车订单 + description: 权限 platform:overseas-business:truck:create;customerId 必填且只作为可信服务端隔离键。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土卡车订单详情 + description: 权限 platform:overseas-business:truck:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + patch: + summary: 平台普通编辑本土卡车订单 + description: 权限 platform:overseas-business:truck:edit;仅待审核订单可编辑,不能修改状态。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/status: + patch: + summary: 推进本土卡车订单状态 + description: 权限 platform:overseas-business:truck:status;普通编辑接口不能修改状态。 + 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 } } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 }, '409': { description: 非法状态迁移 } } + /api/platform/v1/last-leg/us-truck-orders/{orderNo}/receive-pallets: + post: + summary: 记录本土卡车订单实收托盘 + description: 权限 platform:overseas-business:truck:status;幂等记录实收,不创建海外仓出库或派送单。 + 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]*$' } } + responses: { '200': { description: 统一 ApiResponse 包装的卡车订单详情 } } + /api/platform/v1/last-leg/local-looseboard-orders: + get: + summary: 分页查询本土散板订单 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单分页 } } + post: + summary: 创建本土散板订单 + description: 权限 platform:overseas-business:looseboard:create;独立稳定子域,不复用卡车端点。 + security: [{ bearerAuth: [] }] + parameters: [{ name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}: + parameters: + - { name: orderNo, in: path, required: true, schema: { type: string } } + - { name: customerId, in: query, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 查询本土散板订单详情及操作流水 + description: 权限 platform:overseas-business:looseboard:list。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 } } + put: + summary: 全量更新待接单的本土散板订单 + description: 权限 platform:overseas-business:looseboard:edit;可空字段传 null 或空串即清空;状态由独立端点推进。 + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 版本或状态冲突 } } + /api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/status: + patch: + summary: 推进或取消本土散板订单 + description: 权限 platform:overseas-business:looseboard:status;只允许 PENDING_ACCEPTANCE→DISPATCHED→IN_TRANSIT→COMPLETED,未完成节点按规则取消。 + 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 } } } } } } + responses: { '200': { description: 统一 ApiResponse 包装的散板订单详情 }, '409': { description: 非法状态迁移 } } +components: + schemas: + ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } + ProcurementCreate: + type: object + required: [purchaseCategory, productName, purchaseQuantity, contactName, contactPhone, idempotencyKey] + properties: + customerName: { type: string, nullable: true, maxLength: 255 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { type: string, nullable: true, maxLength: 500 } + productUrl: { type: string, nullable: true, maxLength: 2000 } + purchaseQuantity: { type: integer, format: int64, minimum: 1 } + budgetUnitPrice: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + receivingAddress: { type: string, nullable: true, maxLength: 1000 } + contactName: { type: string, maxLength: 128 } + contactPhone: { type: string, maxLength: 64 } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + NullableTextPatch: { type: string, nullable: true, description: 缺失不修改;null 或空串清空 } + ProcurementUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + purchaseCategory: { type: string, maxLength: 128 } + productName: { type: string, maxLength: 255 } + productSpecification: { $ref: '#/components/schemas/NullableTextPatch' } + productUrl: { $ref: '#/components/schemas/NullableTextPatch' } + inboundQuantity: { type: integer, format: int64, minimum: 0 } + purchaseWarehouseId: { $ref: '#/components/schemas/NullableTextPatch' } + purchaseWarehouseName: { $ref: '#/components/schemas/NullableTextPatch' } + domesticCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + domesticTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + overseasCarrier: { $ref: '#/components/schemas/NullableTextPatch' } + overseasTrackingNo: { $ref: '#/components/schemas/NullableTextPatch' } + estimatedArrivalAt: { type: string, format: date-time, nullable: true, description: 缺失不修改;null 或空串清空 } + remark: { $ref: '#/components/schemas/NullableTextPatch' } + CustomerOptionResponse: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { type: array, items: { type: object, properties: { customerId: { type: string }, customerName: { type: string } } } } + WarehouseOptionResponse: + type: object + properties: + 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] } + TruckCreate: + type: object + required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] + properties: + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, nullable: true, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + TruckUpdate: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + palletCount: { type: integer, minimum: 1 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3 } + contactName: { type: string, maxLength: 128 } + contactInfo: { type: string, maxLength: 128 } + requestedDate: { type: string, format: date } + remark: { type: string, nullable: true, maxLength: 1000 } + LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } + LooseboardCreate: + type: object + required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + properties: + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + idempotencyKey: { type: string, maxLength: 255 } + LooseboardUpdate: + type: object + required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm] + properties: + version: { type: integer, format: int64, minimum: 0 } + transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } + palletRequired: { type: string, enum: [YES, NO] } + transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + origin: { type: string, maxLength: 255 } + originAddress: { type: string, nullable: true, maxLength: 500 } + destination: { type: string, maxLength: 255 } + destinationAddress: { type: string, nullable: true, maxLength: 500 } + containerType: { type: string, nullable: true, maxLength: 32 } + packageCount: { type: integer, minimum: 0 } + totalWeightKg: { type: number, minimum: 0 } + totalVolumeCbm: { type: number, minimum: 0 } + budgetAmount: { type: number, nullable: true, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + contactName: { type: string, nullable: true, maxLength: 128 } + contactInfo: { type: string, nullable: true, maxLength: 128 } + requestedDate: { type: string, format: date, nullable: true } + remark: { type: string, nullable: true, maxLength: 1000 } + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml new file mode 100644 index 000000000..f67455ca1 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -0,0 +1,273 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas Master Data API + version: 1.0.0 + description: 海外仓报价、月度汇率与车辆主数据;车辆契约不包含派送履约历史。 +paths: + /api/platform/v1/last-leg/master-data/overseas-quotes: + get: + summary: 分页查询海外仓报价 + description: '权限:platform:overseas-master-data:quote:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建海外仓报价 + description: '权限:platform:overseas-master-data:quote:create;服务端读取国内overseas-partners并缓存合作仓目录30分钟' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteCreate' } } } } + responses: { '200': { description: 业务响应,data为报价详情 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: + get: + summary: 查询缓存的有效海外合作仓选项 + description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口' + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/export: + get: + summary: 导出海外仓报价 + description: '权限:platform:overseas-master-data:quote:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: warehouseId, in: query, schema: { type: string } } + - { name: providerName, in: query, schema: { type: string } } + - { name: currency, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询海外仓报价详情 + description: '权限:platform:overseas-master-data:quote:list' + responses: { '200': { description: 业务响应 } } + put: + summary: 编辑未生效的海外仓报价 + description: '权限:platform:overseas-master-data:quote:edit;recordVersion乐观锁' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/overseas-quotes/{id}/status: + patch: + summary: 启停海外仓报价 + description: '权限:platform:overseas-master-data:quote:status' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/QuoteStatusChange' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates: + get: + summary: 分页查询月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: monthFrom, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: monthTo, in: query, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:create;月份唯一,初始状态ACTIVE' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateCreate' } } } } + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + /api/platform/v1/last-leg/master-data/exchange-rates/summary: + get: + summary: 查询最新有效月度汇率摘要 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data含latestMonth/latestUsdToCnyAverage/updatedBy/updatedAt } } + /api/platform/v1/last-leg/master-data/exchange-rates/export: + get: + summary: 导出筛选后的全部月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:export;UTF-8 BOM CSV' + parameters: + - { name: monthFrom, in: query, schema: { type: string } } + - { name: monthTo, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/ActiveStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}: + parameters: + - { name: month, in: path, required: true, schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } } + get: + summary: 查询月度汇率详情 + description: '权限:platform:overseas-master-data:exchange-rate:list' + responses: { '200': { description: 业务响应,data为MonthlyExchangeRate } } + put: + summary: 编辑月度汇率 + description: '权限:platform:overseas-master-data:exchange-rate:edit;recordVersion冲突返回业务错误' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/exchange-rates/{month}/status: + patch: + summary: 变更月度汇率状态 + description: '权限:platform:overseas-master-data:exchange-rate:status' + parameters: + - { name: month, in: path, required: true, schema: { type: string } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExchangeRateStatus' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles: + get: + summary: 分页查询车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:list' + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Size' + - { name: keyword, in: query, description: 车牌/车型/司机或外派单位, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: 业务响应,data为PageResult } } + post: + summary: 创建车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:create;车牌号唯一' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleCreate' } } } } + responses: { '200': { description: 业务响应,data为Vehicle } } + /api/platform/v1/last-leg/master-data/vehicles/export: + get: + summary: 导出筛选后的全部车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:export;UTF-8 BOM CSV' + parameters: + - { name: keyword, in: query, schema: { type: string } } + - { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } } + - { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } } + - { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } } + responses: { '200': { description: CSV, content: { text/csv: { schema: { type: string, format: binary } } } } } + /api/platform/v1/last-leg/master-data/vehicles/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 查询车辆档案详情 + description: '权限:platform:overseas-master-data:vehicle:list;不包含派送履约或车辆更换历史' + responses: { '200': { description: 业务响应,data为Vehicle } } + put: + summary: 编辑车辆主数据 + description: '权限:platform:overseas-master-data:vehicle:edit;状态由独立接口变更' + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleUpdate' } } } } + responses: { '200': { description: 业务响应 } } + /api/platform/v1/last-leg/master-data/vehicles/{id}/status: + patch: + summary: 变更车辆状态 + description: '权限:platform:overseas-master-data:vehicle:status;使用recordVersion乐观锁' + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/VehicleStatusChange' } } } } + responses: { '200': { description: 业务响应 } } +components: + parameters: + Page: { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + schemas: + ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] } + QuoteFee: + type: object + required: [item, billingUnit, unitPrice] + properties: + item: { type: string, maxLength: 128 } + billingUnit: { type: string, maxLength: 64 } + unitPrice: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteTransitPrice: + type: object + required: [fbaWarehouseCode, deliveryMethod, price] + properties: + fbaWarehouseCode: { type: string, maxLength: 64 } + deliveryMethod: { type: string, maxLength: 64 } + estimatedMiles: { type: number, minimum: 0, nullable: true } + price: { type: number, minimum: 0 } + minimumCharge: { type: number, minimum: 0, nullable: true } + remark: { type: string, maxLength: 500, nullable: true } + QuoteCreate: + type: object + required: [name, warehouseId, providerName, currency, effectiveStart, effectiveEnd, versionCode, warehouseFees] + properties: + name: { type: string, maxLength: 128 } + warehouseId: { type: string, description: 国内平台海外合作仓ID } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + versionCode: { type: string, maxLength: 64 } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteUpdate: + type: object + required: [recordVersion, name, providerName, currency, effectiveStart, effectiveEnd, warehouseFees] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + name: { type: string, maxLength: 128 } + providerName: { type: string, maxLength: 128 } + currency: { type: string, minLength: 3, maxLength: 3 } + effectiveStart: { type: string, format: date } + effectiveEnd: { type: string, format: date } + warehouseFees: { type: array, minItems: 1, items: { $ref: '#/components/schemas/QuoteFee' } } + transitPrices: { type: array, items: { $ref: '#/components/schemas/QuoteTransitPrice' } } + remark: { type: string, maxLength: 1000, nullable: true } + QuoteStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + ExchangeRateCreate: + type: object + required: [month, usdToCnyAverage] + properties: + month: { type: string, example: '2026-08' } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0, example: 7.18540000 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateUpdate: + type: object + required: [recordVersion, usdToCnyAverage] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + usdToCnyAverage: { type: number, exclusiveMinimum: true, minimum: 0 } + remark: { type: string, maxLength: 1000, nullable: true } + ExchangeRateStatus: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/ActiveStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } + remark: { type: string, maxLength: 500, nullable: true } + VehicleOwnership: { type: string, enum: [OWNED, DISPATCHED] } + VehicleRegion: { type: string, enum: [DOMESTIC, OVERSEAS] } + VehicleStatus: { type: string, enum: [IN_SERVICE, DISPATCHED, MAINTENANCE, INACTIVE, RETURNED] } + VehicleCreate: + type: object + required: [plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership, status] + properties: + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + status: { $ref: '#/components/schemas/VehicleStatus' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleUpdate: + type: object + required: [recordVersion, plateNumber, model, fuelConsumptionPer100Km, mileageKm, region, ownership] + properties: + recordVersion: { type: integer, format: int64, minimum: 0 } + plateNumber: { type: string, maxLength: 64 } + model: { type: string, maxLength: 128 } + fuelConsumptionPer100Km: { type: number, exclusiveMinimum: true, minimum: 0 } + mileageKm: { type: integer, format: int64, minimum: 0, description: 不得小于已有公里码 } + driverOrDispatchCompany: { type: string, maxLength: 128, nullable: true } + phone: { type: string, maxLength: 64, nullable: true } + region: { $ref: '#/components/schemas/VehicleRegion' } + ownership: { $ref: '#/components/schemas/VehicleOwnership' } + remark: { type: string, maxLength: 1000, nullable: true } + VehicleStatusChange: + type: object + required: [status, recordVersion] + properties: + status: { $ref: '#/components/schemas/VehicleStatus' } + recordVersion: { type: integer, format: int64, minimum: 0 } 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 a00c65ad8..37475f63c 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 @@ -1,7 +1,10 @@ package com.baiyue.lastleg.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.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; @@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -24,7 +28,9 @@ class LastlegOrderControllerTest { @Test void customerCannotOverrideItsTenantScope() { - controller.list(user(100L, "MERCHANT"), "200", query); + ApiResponse> response = + controller.list(user(100L, "MERCHANT"), "200", query); + assertNotNull(response); verify(service).page(eq("100"), eq(query)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java new file mode 100644 index 000000000..656f50da5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; +import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; +import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; +import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class Phase4BrowserApiBoundaryTest { + @Test + void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() { + assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/")); + assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/")); + assertFalse(basePath(InboundPdaController.class).contains("/api/platform/")); + } + + @Test + void excludedOutboundControllersAreDisabledUnlessExplicitlyEnabledLater() { + for (Class type : new Class[] { + DispatchController.class, DeliveryTransferController.class, DispatchPdaController.class + }) { + ConditionalOnProperty condition = type.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null, type.getSimpleName()); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.future-outbound-api.enabled")); + assertFalse(condition.matchIfMissing()); + } + } + + @Test + void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { + Set paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .filter(annotation -> annotation.annotationType().getName().startsWith("org.springframework.web.bind.annotation")) + .flatMap(annotation -> { + try { + return Arrays.stream((String[]) annotation.annotationType().getMethod("value").invoke(annotation)); + } catch (ReflectiveOperationException ignored) { + return java.util.stream.Stream.empty(); + } + }).collect(Collectors.toSet()); + assertFalse(paths.contains("/allocate")); + assertFalse(paths.contains("/unallocate")); + assertFalse(paths.contains("/pick")); + assertFalse(paths.contains("/ship")); + } + + private static String basePath(Class type) { + return type.getAnnotation(RequestMapping.class).value()[0]; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index d02e94c48..29cbef8a2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -1,7 +1,6 @@ package com.baiyue.lastleg.delivery.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.entity.DeliveryOrder; @@ -204,32 +203,20 @@ class DeliveryTransferServiceTest { } @Test - void outboundDetailReturnsPagedPalletDestinationPackagesAndInboundCode() { + void outboundDetailDoesNotExposeExcludedPalletOrOutboundFulfillmentRows() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101"); DeliveryOrder delivery = delivery(401L, outbound, "DEL-401"); - OutboundPalletItem first = pallet(501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12); - OutboundPalletItem second = pallet(502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8); when(outboundOrders.findByNo("TENANT-A", "OUT-301")).thenReturn(outbound); when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(delivery); - when(outboundPallets.selectPage(any(Page.class), any())).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") Page page = invocation.getArgument(0); - page.setRecords(List.of(first, second)); - page.setTotal(2); - return page; - }); DeliveryResponses.OutboundDetail detail = service.outboundDetail("TENANT-A", "OUT-301", new DeliveryRequests.PalletQuery(1, 20, null)); - PageResult pallets = detail.pallets(); - assertEquals(2, pallets.total()); - assertEquals(2, pallets.records().size()); - assertEquals("ONT8", pallets.records().get(0).destinationWarehouseCode()); - assertEquals("Ontario", pallets.records().get(0).destinationWarehouseName()); - assertEquals(12, pallets.records().get(0).packageCount()); - assertEquals("IN-A", pallets.records().get(0).inboundCode()); - assertEquals("LAX9", pallets.records().get(1).destinationWarehouseCode()); - assertEquals(8, pallets.records().get(1).packageCount()); + assertEquals("OUT-301", detail.outbound().outboundNo()); + assertEquals("DEL-401", detail.delivery().deliveryNo()); + assertTrue(java.util.Arrays.stream(DeliveryResponses.OutboundDetail.class.getRecordComponents()) + .noneMatch(component -> "pallets".equals(component.getName()))); + verify(outboundPallets, never()).selectPage(any(Page.class), any()); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index 32b31ef38..dcc6bac23 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -10,11 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class InboundPlanSchemaContractTest { @Test - void planIsUniqueByTenantAndActualContainerNumber() throws IOException { + void planIsUniqueByCustomerAndSourceOrderWhileContainerCanBeShared() throws IOException { try (var stream = getClass().getClassLoader().getResourceAsStream("db/lastleg.sql")) { assertTrue(stream != null, "lastleg schema must be available"); String schema = new String(stream.readAllBytes(), StandardCharsets.UTF_8).toLowerCase(); - assertTrue(schema.contains("unique key uk_ll_inbound_plan_container (customer_id, container_no)")); + assertTrue(schema.contains("unique key uk_ll_inbound_plan_source_order (customer_id, source_order_id)")); + assertTrue(schema.contains("key idx_ll_inbound_plan_container (customer_id, container_no)")); + assertFalse(schema.contains("unique key uk_ll_inbound_plan_container")); assertTrue(schema.contains("default 'pending_assignment'")); assertTrue(schema.contains("required_package_count")); assertTrue(schema.contains("planned_package_count")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java new file mode 100644 index 000000000..8d3f3b085 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inboundplan.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class InboundPlanPermissionContractTest { + private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + + @Test + void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"page", "detail", "fbaAppointmentDetail"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + LIST + "')", method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 5ab60faa6..1199e5409 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; @@ -42,6 +43,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundStockReceiptServiceTest { @Mock LastlegOrderMapper orders; + @Mock LastlegOrderDetailMapper orderDetails; @Mock InboundStockReceiptMapper receipts; @Mock InventoryCommandService inventory; @Mock DropshipOrderService dropshipOrders; @@ -52,6 +54,7 @@ class InboundStockReceiptServiceTest { void setUp() { service = new InboundStockReceiptService(); ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "orderDetails", orderDetails); ReflectionTestUtils.setField(service, "receipts", receipts); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); @@ -74,6 +77,7 @@ class InboundStockReceiptServiceTest { assertEquals(5L, request.getValue().packages()); assertEquals(101L, request.getValue().inboundOrderId()); assertEquals("IN-1", request.getValue().inboundCode()); + assertEquals("SKU-1", request.getValue().skuCode()); ArgumentCaptor receipt = ArgumentCaptor.forClass(InboundStockReceipt.class); verify(receipts).insert(receipt.capture()); assertEquals(5L, receipt.getValue().getReceivedPackages()); @@ -84,10 +88,11 @@ class InboundStockReceiptServiceTest { } @Test - void storesOneInboundOrderAcrossMultiplePalletsWithoutSkuSplitting() { + void splitsOneInboundOrderAcrossSkuAndPalletDimensions() { Fixture fixture = fixture(10, 5, 5, 10); fixture.detail.setPackageCount(5); LastlegOrderDetail second = detail(202L, "SKU-2", 5, 7); + when(orderDetails.selectList(any())).thenReturn(List.of(fixture.detail, second)); InboundPlanPallet secondPallet = pallet(22L, 2002L); InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); @@ -187,6 +192,7 @@ class InboundStockReceiptServiceTest { order.setCurrentStatus("INBOUND_PLANNED"); org.mockito.Mockito.lenient().when(orders.lockById("100", 101L)).thenReturn(order); LastlegOrderDetail detail = detail(201L, "SKU-1", required, quantity); + org.mockito.Mockito.lenient().when(orderDetails.selectList(any())).thenReturn(List.of(detail)); return new Fixture(plan, task, line, order, detail, allocation(31L, 12L, 21L, picked), pallet(21L, 2001L)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java new file mode 100644 index 000000000..0e4946ebb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.common.core.api.ApiResponse; +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.OverseasContainerUnloadedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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 java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InboundUnloadCompletedOutboxRelayTest { + @Mock private UnloadCompletedOutboxRepository repository; + @Mock private EventBridgeClient client; + private ObjectMapper objectMapper; + private InboundUnloadCompletedOutboxRelay relay; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + relay = new InboundUnloadCompletedOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); + ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + } + + @Test + void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement( + event.eventId(), "已接收", "2026-08-20T03:01:00"))); + + relay.relay(); + + verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class)); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + + @Test + void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenThrow(new IllegalStateException("bridge unavailable")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + private LastlegEventOutbox row(EventEnvelope event) throws Exception { + LastlegEventOutbox row = new LastlegEventOutbox(); + row.setId(9001L); + row.setEventKey("OverseasContainerUnloaded:7001"); + row.setPayloadJson(objectMapper.writeValueAsString(event)); + return row; + } + + private EventEnvelope event() { + var payload = objectMapper.valueToTree(new OverseasContainerUnloadedV1("1001", "MSCU1234567", + Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null)); + return new EventEnvelope("00000000-0000-4000-8000-000000000009", + EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.parse("2026-08-20T03:00:01Z"), + RegionCode.OVERSEAS, RegionCode.CN, "1", "CONTAINER_BOOKING", "1001", 7L, 1L, + "trace-12345678", "correlation-12345678", null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index a534f549d..30261d8ff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; @@ -22,6 +24,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; +import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -115,6 +118,35 @@ class InboundPlanServiceTest { verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); } + @Test + void pagesInboundPlansWithinCustomerScope() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query( + 2, 20, "LIP", "RK", "PENDING_ASSIGNMENT", "WH-1", "ONT8", "ONT8", + "NOT_APPOINTED", null, null); + InboundPlanSummary row = new InboundPlanSummary(1L, "LIP1", 2L, "100", "Customer A", + "FBA", "RK-1", "CONT-1", "WH-1", "LA Warehouse", "ONT8", "ONT8", "ONT8", + "ONT8", 10, BigDecimal.ONE, 1, "PENDING_ASSIGNMENT", "NOT_APPOINTED", null, null); + when(planMapper.countRows("100", query)).thenReturn(21L); + when(planMapper.pageRows("100", query, 20L, 20L)).thenReturn(List.of(row)); + + var result = service.page("100", query); + + assertEquals(21L, result.total()); + assertEquals(2L, result.pages()); + assertEquals("RK-1", result.records().get(0).inboundCode()); + verify(planMapper).pageRows("100", query, 20L, 20L); + } + + @Test + void rejectsReversedInboundPlanDateRange() { + InboundPlanRequests.Query query = new InboundPlanRequests.Query(1, 20, null, null, null, null, + null, null, null, LocalDateTime.of(2026, 8, 20, 0, 0), + LocalDateTime.of(2026, 8, 19, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.page("100", query)); + verify(planMapper, never()).countRows(any(), any()); + } + @Test void rejectsMissingDestinationWarehouse() { SourceOrderSnapshot invalid = new SourceOrderSnapshot(1L, "100", "CONT-1", "RK-1", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index a5f96dab4..d472d0a53 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -28,12 +28,13 @@ class InventorySchemaContractTest { } assertTrue(schema.contains("create table if not exists ll_inventory_balance")); - assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)")); + assertTrue(schema.contains("unique key uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)")); assertTrue(schema.contains("inbound_order_id bigint not null")); assertTrue(schema.contains("inbound_code varchar(128) not null")); assertTrue(schema.contains("available_packages bigint not null default 0")); assertTrue(schema.contains("in_transit_packages bigint not null default 0")); - assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("sku_code")); + assertTrue(tableDefinition(schema, "ll_inventory_balance").contains("product_name_zh")); assertFalse(tableDefinition(schema, "ll_inventory_balance").contains("_quantity")); assertTrue(schema.contains("create table if not exists ll_inventory_position")); assertTrue(schema.contains("unique key uk_ll_inventory_position (balance_id,pallet_id,batch_no)")); @@ -52,7 +53,7 @@ class InventorySchemaContractTest { assertTrue(schema.contains("unique key uk_ll_inventory_ledger_idempotency (customer_id,idempotency_key)")); assertTrue(schema.contains("create table if not exists ll_inventory_operation")); assertTrue(schema.contains("create table if not exists ll_inbound_stock_receipt")); - assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation (customer_id,task_id,allocation_id)")); + assertTrue(schema.contains("unique key uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)")); assertFalse(tableDefinition(schema, "ll_inventory_ledger").contains("updated_at")); } @@ -97,7 +98,8 @@ class InventorySchemaContractTest { assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId")); assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode")); assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId")); - assertThrowsNoField(InventoryBalance.class, "skuCode"); + assertNotNull(InventoryBalance.class.getDeclaredField("skuCode")); + assertNotNull(InventoryBalance.class.getDeclaredField("productNameZh")); assertThrowsNoField(InventoryBalance.class, "availableQuantity"); assertThrowsNoField(InventoryPosition.class, "skuCode"); assertThrowsNoField(InventoryPosition.class, "productQuantity"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index b85001fd9..c684f5c47 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -63,7 +63,7 @@ class InventoryCommandServiceTest { void stockInUpdatesBalancePositionAndLedgerInOneTransaction() throws Exception { assertTrue(InventoryCommandService.class.getMethod("stockIn", String.class, String.class, InventoryCommandRequests.StockIn.class).getAnnotation(Transactional.class) != null); - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-1", 12, 24)); assertEquals(12, result.availablePackages()); @@ -71,6 +71,7 @@ class InventoryCommandServiceTest { ArgumentCaptor balance = ArgumentCaptor.forClass(InventoryBalance.class); verify(balances).updateById(balance.capture()); assertEquals(12L, balance.getValue().getAvailablePackages()); + assertEquals("SKU-1", balance.getValue().getSkuCode()); verify(positions).insert(any(InventoryPosition.class)); verify(ledgers).insert(any(InventoryLedger.class)); verify(operations).updateById(any(InventoryOperation.class)); @@ -78,7 +79,7 @@ class InventoryCommandServiceTest { @Test void stockInAllowsPalletPositionWithZeroOptionalPackages() { - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0)); @@ -104,7 +105,7 @@ class InventoryCommandServiceTest { @Test void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception { var request = stockIn("IN-1", "stock-in-1", 12, 24); - when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null); + when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1", "SKU-1")).thenReturn(null); service.stockIn("100", "7", request); ArgumentCaptor operation = ArgumentCaptor.forClass(InventoryOperation.class); verify(operations).insert(operation.capture()); @@ -138,7 +139,8 @@ class InventoryCommandServiceTest { private InventoryCommandRequests.StockIn stockIn(String inbound, String key, long packages, long quantity) { return new InventoryCommandRequests.StockIn("100", "Customer 100", "WH-1", "WH1", "Warehouse 1", - "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", 300L, "PLT-1", null, null, null, + "DEST-1", "ONT8", "ONT8", 101L, inbound, "IN-CODE-1", "SKU-1", "Product 1", null, + 300L, "PLT-1", null, null, null, packages, "ORD-1", "EVT-1", key, null); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java index 451032574..d469556ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java @@ -18,7 +18,7 @@ class InventoryQueryControllerTest { private final InventoryQueryService service = mock(InventoryQueryService.class); private final InventoryQueryController controller = controller(); private final InventoryQueryRequests.BalanceQuery query = new InventoryQueryRequests.BalanceQuery( - 1, 20, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null); @Test void merchantCannotOverrideCustomerScope() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java index 1bbfa8ad6..d164e0cc2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java @@ -120,12 +120,11 @@ class InventoryQueryServiceTest { String csv = new String(service.exportBalances("100", query), StandardCharsets.UTF_8); - assertTrue(csv.contains("inboundOrderNo,inboundCode,totalPackages")); - assertFalse(csv.contains("skuCode")); + assertTrue(csv.contains("inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages")); assertFalse(csv.contains("Quantity")); } private static InventoryQueryRequests.BalanceQuery balanceQuery(String status) { - return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, status); + return new InventoryQueryRequests.BalanceQuery(1, 20, null, null, null, null, null, null, null, null, null, status); } } 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 new file mode 100644 index 000000000..341cbe27c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.looseboard; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LocalLooseboardSchemaContractTest { + @Test + void baselineContainsIndependentLooseboardOrderAndHistory() throws Exception { + String sql; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + 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("operation_type VARCHAR(32) NOT NULL")); + assertTrue(sql.contains("uk_ll_local_looseboard_no(customer_id,order_no)")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java new file mode 100644 index 000000000..ccd1af921 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.looseboard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.lastleg.looseboard.service.LocalLooseboardService; +import com.baiyue.lastleg.security.OverseasPermissions; +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.time.LocalDate; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PlatformLocalLooseboardControllerTest { + @Mock LocalLooseboardService service; + private PlatformLocalLooseboardController controller; + + @BeforeEach + void setUp() { + controller = new PlatformLocalLooseboardController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + void createUsesExplicitCustomerAndOperator() { + var request = create(); + controller.create(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", request); + verify(service).create("100", "7", request); + } + + @Test + void updateRequiresIndependentEditPermission() { + var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP", + "LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, + null, "USD", null, null, null, null); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); + assertEquals(403, error.getStatusCode().value()); + } + + private LocalLooseboardRequests.Create create() { + return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null, + "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", + null, null, LocalDate.now(), null, "key-1"); + } + + private AuthenticatedUser user(Set permissions) { + return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", permissions); + } +} 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 new file mode 100644 index 000000000..cd60f508c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -0,0 +1,113 @@ +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.mapper.LocalLooseboardHistoryMapper; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +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 org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.when; + +@ExtendWith(MockitoExtension.class) +class LocalLooseboardServiceTest { + @Mock LocalLooseboardOrderMapper orders; + @Mock LocalLooseboardHistoryMapper histories; + private LocalLooseboardService service; + + @BeforeEach + void setUp() { + service = new LocalLooseboardService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void createIsTenantScopedIdempotentAndStartsPendingAcceptance() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(LocalLooseboardOrder.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("100", detail.summary().customerId()); + assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); + assertEquals("SHARED_TRUCK", detail.summary().transportMode()); + verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> + "CREATE".equals(value.getOperationType()))); + } + + @Test + void ordinaryUpdateCannotChangeAcceptedOrder() { + LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.update("100", "LB1", "7", update(0L))); + + assertEquals(409, error.getStatusCode().value()); + } + + @Test + void statusTransitionWritesHistoryAndVersion() { + LocalLooseboardOrder order = order(LocalLooseboardService.PENDING_ACCEPTANCE, 0L); + when(orders.lockNo("100", "LB1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + + var detail = service.changeStatus("100", "LB1", "7", + new LocalLooseboardRequests.ChangeStatus(0L, "DISPATCHED", "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()))); + } + + private LocalLooseboardRequests.Create create(String key) { + return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", + "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + } + + private LocalLooseboardRequests.Update update(long version) { + return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", + "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null); + } + + private LocalLooseboardOrder order(String status, long version) { + LocalLooseboardOrder order = new LocalLooseboardOrder(); + order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); + order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES"); + order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); + order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); + order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(order.getCreatedAt()); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java new file mode 100644 index 000000000..b9b072a47 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.masterdata; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasMasterDataOpenApiContractTest { + @Test + void documentsExchangeRateAndVehicleApisWithoutDeliveryFulfillment() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml")); + assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:") + .contains("缓存合作仓目录30分钟") + .contains("/api/platform/v1/last-leg/master-data/exchange-rates:") + .contains("/api/platform/v1/last-leg/master-data/vehicles:") + .contains("platform:overseas-master-data:exchange-rate:export") + .contains("platform:overseas-master-data:vehicle:status") + .contains("不包含派送履约或车辆更换历史") + .doesNotContain("deliveryOrders:") + .doesNotContain("replacementHistory:"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java new file mode 100644 index 000000000..e1f110696 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.masterdata.exchange; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MonthlyExchangeRateSqlContractTest { + @Test + void baselineContainsRateAndHistoryTablesWithOptimisticLock() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (") + .contains("UNIQUE KEY uk_ll_exchange_rate_month (rate_month)") + .contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'") + .contains("CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history ("); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java new file mode 100644 index 000000000..cf72432f1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.masterdata.exchange.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformMonthlyExchangeRateControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformMonthlyExchangeRateController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/exchange-rates"); + Map authorities = Stream.of(PlatformMonthlyExchangeRateController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("summary")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:exchange-rate:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:exchange-rate:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:exchange-rate:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:exchange-rate:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:exchange-rate:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java new file mode 100644 index 000000000..cbb0ba739 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.masterdata.exchange.service; + +import com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos; +import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateHistoryMapper; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MonthlyExchangeRateServiceTest { + private MonthlyExchangeRateMapper mapper; + private MonthlyExchangeRateService service; + + @BeforeEach + void setUp() { + mapper = mock(MonthlyExchangeRateMapper.class); + service = new MonthlyExchangeRateService(); + ReflectionTestUtils.setField(service, "rates", mapper); + ReflectionTestUtils.setField(service, "histories", mock(MonthlyExchangeRateHistoryMapper.class)); + } + + @Test + void rejectsStaleVersion() { + MonthlyExchangeRate value = new MonthlyExchangeRate(); value.setRecordVersion(4L); value.setRateMonth("2026-08"); + when(mapper.lockMonth("2026-08")).thenReturn(value); + var request = new MonthlyExchangeRateDtos.Update(3L, new BigDecimal("7.18000000"), null); + assertThatThrownBy(() -> service.update("2026-08", "9", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409 CONFLICT"); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java new file mode 100644 index 000000000..6692c7359 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.masterdata.quote.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOverseasQuoteControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformOverseasQuoteController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/overseas-quotes"); + Map authorities = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), + method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("warehouseOptions")).contains("platform:overseas-master-data:quote:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:quote:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java new file mode 100644 index 000000000..5d47f9e32 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.masterdata.quote.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.masterdata.quote.client.DomesticPartnerWarehouseClient; +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.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PartnerWarehouseDirectoryTest { + @Mock private DomesticPartnerWarehouseClient client; + private PartnerWarehouseDirectory directory; + + @BeforeEach + void setUp() { + directory = new PartnerWarehouseDirectory(); + ReflectionTestUtils.setField(directory, "client", client); + ReflectionTestUtils.setField(directory, "internalToken", "internal-token"); + ReflectionTestUtils.setField(directory, "ttl", Duration.ofMinutes(30)); + ReflectionTestUtils.setField(directory, "clock", Clock.fixed( + Instant.parse("2026-08-20T00:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void cachesDomesticOptionsForThirtyMinutesAndReturnsIdsAsStrings() { + when(client.activePartners("internal-token")).thenReturn(ApiResponse.success(List.of( + new DomesticPartnerWarehouseClient.Option(Long.valueOf("9007199254740993"), + "US-LAX", "洛杉矶合作仓", "US")))); + + assertThat(directory.options()).singleElement().satisfies(option -> { + assertThat(option.warehouseId()).isEqualTo("9007199254740993"); + assertThat(option.warehouseCode()).isEqualTo("US-LAX"); + assertThat(option.warehouseName()).isEqualTo("洛杉矶合作仓"); + assertThat(option.countryCode()).isEqualTo("US"); + }); + directory.options(); + verify(client, times(1)).activePartners("internal-token"); + + ReflectionTestUtils.setField(directory, "clock", Clock.fixed( + Instant.parse("2026-08-20T00:30:01Z"), ZoneOffset.UTC)); + directory.options(); + verify(client, times(2)).activePartners("internal-token"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java new file mode 100644 index 000000000..cde36e0a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.masterdata.vehicle; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class VehicleSqlContractTest { + @Test + void baselineContainsVehicleMasterDataWithoutDeliveryHistory() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + String vehicleSql = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS ll_vehicle (")); + assertThat(vehicleSql).contains("UNIQUE KEY uk_ll_vehicle_plate (plate_number)") + .contains("record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'") + .contains("ownership VARCHAR(16) NOT NULL") + .doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_delivery") + .doesNotContain("CREATE TABLE IF NOT EXISTS ll_vehicle_replacement"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java new file mode 100644 index 000000000..719dcf8e3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.masterdata.vehicle.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformVehicleControllerContractTest { + @Test + void exposesStablePlatformBaseAndCanonicalAuthorities() { + RequestMapping base = PlatformVehicleController.class.getAnnotation(RequestMapping.class); + assertThat(base.value()).containsExactly("/api/platform/v1/last-leg/master-data/vehicles"); + Map authorities = Stream.of(PlatformVehicleController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(method -> method.getName(), method -> method.getAnnotation(PreAuthorize.class).value())); + assertThat(authorities.get("page")).contains("platform:overseas-master-data:vehicle:list"); + assertThat(authorities.get("detail")).contains("platform:overseas-master-data:vehicle:list"); + assertThat(authorities.get("create")).contains("platform:overseas-master-data:vehicle:create"); + assertThat(authorities.get("update")).contains("platform:overseas-master-data:vehicle:edit"); + assertThat(authorities.get("status")).contains("platform:overseas-master-data:vehicle:status"); + assertThat(authorities.get("export")).contains("platform:overseas-master-data:vehicle:export"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java new file mode 100644 index 000000000..b83562614 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.masterdata.vehicle.service; + +import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; +import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; +import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VehicleServiceTest { + private VehicleMapper mapper; + private VehicleService service; + + @BeforeEach + void setUp() { + mapper = mock(VehicleMapper.class); + service = new VehicleService(); + ReflectionTestUtils.setField(service, "vehicles", mapper); + } + + @Test + void rejectsStaleVersionBeforeUpdating() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + VehicleDtos.Update request = update(2L, 1000L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409 CONFLICT"); + } + + @Test + void rejectsDecreasingOdometer() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + VehicleDtos.Update request = update(3L, 999L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400 BAD_REQUEST"); + } + + @Test + void rejectsUnknownStatus() { + when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); + assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L))) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("400 BAD_REQUEST"); + } + + private static VehicleDtos.Update update(long version, long mileage) { + return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", null); + } + + private static Vehicle vehicle(long version, long mileage) { + Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage); + value.setStatus("IN_SERVICE"); return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 0e2a97a8a..7e5702813 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -21,5 +21,8 @@ class OverseasProcurementInventoryBoundaryTest { assertFalse(source.contains("stockIn(")); assertFalse(source.contains("setAvailablePackages")); assertFalse(source.contains("availablePackages")); + assertFalse(source.contains("addOutbound")); + assertFalse(source.contains("OverseasProcurementOutbound")); + assertFalse(source.contains("outboundQuantity")); } } 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 new file mode 100644 index 000000000..dcd83c472 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.procurement; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OverseasProcurementOpenApiContractTest { + @Test + void procurementContractDocumentsCandidatesClearSemanticsAndExcludesOutbound() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(yaml.contains("/customer-options:")); + assertTrue(yaml.contains("/warehouse-options:")); + assertTrue(yaml.contains("显式 null 或空串表示清空")); + assertTrue(yaml.contains("platform:overseas-business:purchase:list")); + assertTrue(yaml.contains("platform:overseas-business:purchase:edit")); + assertFalse(yaml.contains("/outbounds")); + assertFalse(yaml.contains("采购出库单")); + } + + @Test + void contractContainsPlatformTruckWritesAndIndependentLooseboardDomain() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(yaml.contains("platform:overseas-business:truck:create")); + assertTrue(yaml.contains("platform:overseas-business:truck:edit")); + 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")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:edit")); + assertTrue(yaml.contains("platform:overseas-business:looseboard:status")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/outbound")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/delivery")); + } +} 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 d9d7cdba0..8560aa6b6 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 @@ -8,14 +8,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class OverseasProcurementSchemaContractTest { @Test - void baselineContainsOrderOutboundAndReviewHistoryTables() throws Exception { + void baselineContainsOrderAndOperationHistoryButNoProcurementOutbound() throws Exception { String sql; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { sql = new String(input.readAllBytes(), StandardCharsets.UTF_8); } assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order")); - assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history")); - assertTrue(sql.contains("UNIQUE KEY uk_ll_overseas_procurement_outbound_no(customer_id,order_id,outbound_no)")); + assertTrue(sql.contains("operation_type VARCHAR(32) NOT NULL")); + assertTrue(sql.contains("change_detail JSON NULL")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("CREATE TABLE IF NOT EXISTS ll_overseas_procurement_outbound")); + org.junit.jupiter.api.Assertions.assertFalse(sql.contains("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 61559b9ee..c06909add 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 @@ -62,6 +62,15 @@ class OverseasProcurementControllerTest { verify(service).review("100", "HC1", "7", request); } + @Test + void platformUpdatePassesAuditedOperator() { + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + null, null, null, null, null, null, null, null, null); + platform.update(user(0L, "PLATFORM", Set.of(OverseasProcurementService.MANAGE_PERMISSION)), + "100", "HC1", request); + verify(service).update("100", "HC1", "7", request); + } + private AuthenticatedUser user(long tenantId, String appCode, Set permissions) { return new AuthenticatedUser(7L, tenantId, tenantId, 8L, "operator", appCode, "WEB", permissions); } 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 308801744..f60b35062 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 @@ -3,8 +3,9 @@ package com.baiyue.lastleg.procurement.service; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; -import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOutboundMapper; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementStatusHistoryMapper; +import com.baiyue.lastleg.masterdata.quote.service.PartnerWarehouseDirectory; +import com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteResponses; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,21 +24,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; 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.never; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; - @Mock OverseasProcurementOutboundMapper outbounds; @Mock OverseasProcurementStatusHistoryMapper histories; + @Mock PartnerWarehouseDirectory warehouseDirectory; private OverseasProcurementService service; @BeforeEach void setUp() { service = new OverseasProcurementService(); ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "outbounds", outbounds); ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); } @@ -49,7 +52,6 @@ class OverseasProcurementServiceTest { return 1; }); when(orders.findNo(anyString(), anyString())).thenAnswer(ignored -> saved.get()); - when(outbounds.listOrder(anyString(), any())).thenReturn(List.of()); when(histories.listOrder(anyString(), any())).thenReturn(List.of()); var detail = service.create("100", "7", new OverseasProcurementRequests.Create( @@ -69,21 +71,75 @@ class OverseasProcurementServiceTest { 100L, null, null, null, null, null, null, null, null); ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.update("100", "HC1", request)); + () -> service.update("100", "HC1", "7", request)); assertEquals(409, error.getStatusCode().value()); } @Test - void outboundCannotExceedDerivedInventory() { - OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 80); + void explicitNullClearsOptionalFieldsAndWritesOperationHistory() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + order.setProductUrl("https://example.test/old"); when(orders.lockNo("100", "HC1")).thenReturn(order); - when(orders.addOutbound(order.getId(), "100", 30)).thenReturn(0); - var request = new OverseasProcurementRequests.AddOutbound(30L, "OUT-1", null, null, - LocalDateTime.now(), null); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + var request = new OverseasProcurementRequests.Update(0L, null, null, + new ObjectMapper().nullNode(), new ObjectMapper().nullNode(), null, + null, null, null, null, null, null, null, null); - ResponseStatusException error = assertThrows(ResponseStatusException.class, - () -> service.addOutbound("100", "HC1", "7", request)); - assertEquals(409, error.getStatusCode().value()); + service.update("100", "HC1", "7", request); + + assertEquals(null, order.getProductUrl()); + verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> + "UPDATE".equals(value.getOperationType()) && value.getChangeDetail().contains("productUrl"))); + } + + @Test + void omittedOptionalFieldKeepsExistingValue() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + order.setProductUrl("https://example.test/keep"); + 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.Update(0L, null, null, null, null, + null, null, null, null, null, null, null, null, null); + + service.update("100", "HC1", "7", request); + + assertEquals("https://example.test/keep", order.getProductUrl()); + verify(orders, never()).updateById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void warehouseSelectionFreezesCanonicalCandidateName() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 100, 0); + when(orders.lockNo("100", "HC1")).thenReturn(order); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + when(warehouseDirectory.required(10L)).thenReturn( + new OverseasQuoteResponses.PartnerWarehouseOption("10", "LAX-01", "洛杉矶采购仓", "US")); + ObjectMapper mapper = new ObjectMapper(); + var request = new OverseasProcurementRequests.Update(0L, null, null, null, null, + null, mapper.getNodeFactory().textNode("10"), mapper.getNodeFactory().textNode("伪造名称"), + null, null, null, null, null, null); + + service.update("100", "HC1", "7", request); + + assertEquals("10", order.getPurchaseWarehouseId()); + assertEquals("洛杉矶采购仓", order.getPurchaseWarehouseName()); + } + + @Test + void customerOptionsAreDeduplicatedSnapshotsFromProcurementOrders() { + OverseasProcurementOrder option = new OverseasProcurementOrder(); + option.setCustomerId("100"); + option.setCustomerName("Acme"); + when(orders.customerOptions("ac", 20)).thenReturn(List.of(option)); + + var values = service.customerOptions(" ac ", 20); + + assertEquals(1, values.size()); + assertEquals("100", values.get(0).customerId()); + assertEquals("Acme", values.get(0).customerName()); } private OverseasProcurementOrder order(String status, long inbound, long outbound) { @@ -93,7 +149,6 @@ class OverseasProcurementServiceTest { order.setOrderNo("HC1"); order.setStatus(status); order.setInboundQuantity(inbound); - order.setOutboundQuantity(outbound); order.setPurchaseQuantity(500L); order.setVersion(0L); order.setCreatedAt(LocalDateTime.now()); 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 bab9745c3..551219f83 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 @@ -26,10 +26,13 @@ class SchemaChineseCommentContractTest { Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); String incremental = Files.readString( Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); + String overseasUnified = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); List baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55); assertAllChineseComments(incremental, "增量 SQL", 49); - assertCommentMigrationCovers(incremental, baselineTables); + assertAllChineseComments(overseasUnified, "海外统一增量 SQL", 10); + assertCommentMigrationCovers(incremental, overseasUnified, baselineTables); } @Test @@ -78,21 +81,48 @@ class SchemaChineseCommentContractTest { return tables; } - private static void assertCommentMigrationCovers(String incremental, List tables) { + private static void assertCommentMigrationCovers( + String incremental, + String overseasUnified, + List tables + ) { int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移"); String migration = incremental.substring(marker); for (TableDefinition table : tables) { - assertTrue(migration.contains("table_name='" + table.name() + "'"), + boolean createdInLegacy = hasCommentedCreate(migration, table.name()); + boolean createdInUnified = hasCommentedCreate(overseasUnified, table.name()); + assertTrue(createdInLegacy || createdInUnified + || migration.contains("table_name='" + table.name() + "'"), "025 备注迁移缺少表: " + table.name()); + if (createdInLegacy || createdInUnified) { + continue; + } for (String column : table.columns()) { - assertTrue(migration.contains("table_name='" + table.name() - + "' AND column_name='" + column + "'"), + boolean repaired = migration.contains("table_name='" + table.name() + + "' AND column_name='" + column + "'"); + boolean addedWithComment = hasCommentedAdd(migration, column) + || hasCommentedAdd(overseasUnified, column); + assertTrue(repaired || addedWithComment, "025 备注迁移缺少字段: " + table.name() + "." + column); } } } + private static boolean hasCommentedCreate(String sql, String table) { + return Pattern.compile( + "(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + Pattern.quote(table) + + "\\s*\\(.*?\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'[^']*[\\u4e00-\\u9fff][^']*'\\s*;") + .matcher(sql).find(); + } + + private static boolean hasCommentedAdd(String sql, String column) { + return Pattern.compile( + "(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column) + + "\\b[^;]*?COMMENT\\s+''?[^;]*[\\u4e00-\\u9fff][^;]*''?") + .matcher(sql).find(); + } + private static boolean isConstraint(String definition) { String normalized = definition.stripLeading().toUpperCase(); return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index b2eae4f27..4ed18e1ee 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -7,12 +7,6 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; -import com.baiyue.lastleg.dropship.entity.DropshipCarton; -import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; -import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory; -import com.baiyue.lastleg.dropship.mapper.DropshipCartonMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; -import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,9 +31,6 @@ class LastlegOrderServiceTest { @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; - @Mock DropshipOrderProfileMapper dropshipProfiles; - @Mock DropshipSkuInventoryMapper dropshipSkuInventory; - @Mock DropshipCartonMapper dropshipCartons; LastlegOrderService service; @BeforeEach @@ -48,35 +39,23 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); - ReflectionTestUtils.setField(service, "dropshipProfiles", dropshipProfiles); - ReflectionTestUtils.setField(service, "dropshipSkuInventory", dropshipSkuInventory); - ReflectionTestUtils.setField(service, "dropshipCartons", dropshipCartons); } @Test - void detailGroupsDropshipSkuAndCartonsByPallet() { + void detailDoesNotExposeDropshipCartonsOrInternalEventIds() { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); order.setOrderType("ONE_CLICK_DROPSHIP"); when(orders.selectOne(any())).thenReturn(order); when(details.selectList(any())).thenReturn(List.of()); when(histories.selectList(any())).thenReturn(List.of()); - DropshipOrderProfile first = profile(1L, "SOURCE-P-1", 101L, "WH-P-1"); - DropshipOrderProfile second = profile(2L, "SOURCE-P-2", 102L, "WH-P-2"); - when(dropshipProfiles.listOrder("100", 11L)).thenReturn(List.of(first, second)); - DropshipSkuInventory sku = new DropshipSkuInventory(); - sku.setProfileId(1L); sku.setSkuCode("SKU-A"); sku.setPackageCount(1L); sku.setProductQuantity(10L); - when(dropshipSkuInventory.listOrder("100", 11L)).thenReturn(List.of(sku)); - DropshipCarton carton = new DropshipCarton(); - carton.setProfileId(1L); carton.setSkuCode("SKU-A"); carton.setCartonMark("MARK-1"); - when(dropshipCartons.listOrder("100", 11L)).thenReturn(List.of(carton)); var response = service.detail("100", 11L); - assertEquals(2, response.dropshipPallets().size()); - assertEquals("SOURCE-P-1", response.dropshipPallets().get(0).sourcePalletNo()); - assertEquals(1, response.dropshipPallets().get(0).skuInventory().size()); - assertEquals("MARK-1", response.dropshipPallets().get(0).cartons().get(0).getCartonMark()); - assertTrue(response.dropshipPallets().get(1).skuInventory().isEmpty()); + assertEquals(11L, response.order().id()); + assertTrue(java.util.Arrays.stream(response.getClass().getRecordComponents()) + .noneMatch(component -> "dropshipPallets".equals(component.getName()))); + assertTrue(java.util.Arrays.stream(com.baiyue.lastleg.dto.LastlegOrderDetailResponse.History.class.getRecordComponents()) + .noneMatch(component -> "eventId".equals(component.getName()))); } @Test @@ -133,12 +112,4 @@ class LastlegOrderServiceTest { return order; } - private static DropshipOrderProfile profile(long id, String sourcePalletNo, - long warehousePalletId, String palletNo) { - DropshipOrderProfile profile = new DropshipOrderProfile(); - profile.setId(id); profile.setSourcePalletNo(sourcePalletNo); - profile.setWarehousePalletId(warehousePalletId); profile.setPalletNo(palletNo); - profile.setStatus("RECEIVED"); - return profile; - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 58ee4bcef..f4b449c62 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -206,6 +206,7 @@ class WarehouseTransferServiceTest { when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation)); + when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 101L, "IN-1")); when(orders.selectById(31L)).thenReturn(tailOrder); when(transferItems.selectList(any())).thenReturn(List.of()); when(sourceOrders.selectList(any())).thenReturn(List.of()); @@ -237,6 +238,7 @@ class WarehouseTransferServiceTest { ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class); verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture()); assertEquals(5, stockIn.getValue().packages()); + assertEquals("SKU-TRANSFER", stockIn.getValue().skuCode()); assertEquals("WTR-1", stockIn.getValue().businessOrderNo()); assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey()); assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus()); @@ -350,7 +352,7 @@ class WarehouseTransferServiceTest { private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) { InventoryBalance balance = new InventoryBalance(); balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId); - balance.setInboundCode(inboundCode); + balance.setInboundCode(inboundCode); balance.setSkuCode("SKU-TRANSFER"); return balance; } 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 e70f33db3..fdcb0caf1 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 @@ -50,6 +50,25 @@ class UsTruckOrderControllerTest { verify(service).changeStatus("100", "TK1", "7", request); } + @Test + void platformCreateUsesExplicitCustomerAndCreatePermission() { + var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, + "Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, + null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key"); + platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)), + "100", request); + verify(service).create("100", "7", request); + } + + @Test + void platformUpdateUsesExplicitCustomerAndEditPermission() { + var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null); + platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)), + "100", "TK1", request); + verify(service).update("100", "TK1", "7", request); + } + private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); } private AuthenticatedUser user(long tenant, String app, Set permissions) { return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions); diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 298df8d32..df758b85f 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -1517,6 +1517,22 @@ PRIMARY KEY(id), UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id, KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status) ) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';+ -- ============================================================================ +CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton ( + id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID', + sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID', + line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID', + warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID', + status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark), + KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), + KEY idx_ll_inbound_carton_order(order_id,status) +) ENGINE=InnoDB COMMENT='柜次冻结箱唛及海外入库箱级作业账'; + -- 2026-08-18 / 025 Chinese table and column comments for Lastleg and Warehouse. -- Idempotently updates metadata for databases where CREATE TABLE already ran. -- ============================================================================ @@ -1636,6 +1652,50 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_id' AND COALESCE(column_comment,'') <> '来源装柜计划 ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT ''来源装柜计划 ID''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id' AND COALESCE(column_comment,'') <> '来源头程货柜预订ID'), 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND COALESCE(table_comment,'') <> '柜次冻结箱唛及海外入库箱级作业账'), 'ALTER TABLE ll_inbound_plan_carton COMMENT = ''柜次冻结箱唛及海外入库箱级作业账''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='id' AND COALESCE(column_comment,'') <> '箱唛作业主键'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN id BIGINT NOT NULL COMMENT ''箱唛作业主键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='customer_id' AND COALESCE(column_comment,'') <> '客户租户ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='order_id' AND COALESCE(column_comment,'') <> '海外首扫确权后的尾程订单ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN order_id BIGINT NULL COMMENT ''海外首扫确权后的尾程订单ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='source_inbound_detail_id' AND COALESCE(column_comment,'') <> '来源入库明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT ''来源入库明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='sku_code' AND COALESCE(column_comment,'') <> 'SKU编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='carton_mark' AND COALESCE(column_comment,'') <> '国内冻结箱唛'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN carton_mark VARCHAR(128) NOT NULL COMMENT ''国内冻结箱唛''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_id' AND COALESCE(column_comment,'') <> '目的仓ID快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''目的仓ID快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_code' AND COALESCE(column_comment,'') <> '目的仓编码快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='destination_warehouse_name' AND COALESCE(column_comment,'') <> '目的仓名称快照'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_id' AND COALESCE(column_comment,'') <> '海外入库计划ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_id BIGINT NULL COMMENT ''海外入库计划ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='line_id' AND COALESCE(column_comment,'') <> '入库计划明细ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN line_id BIGINT NULL COMMENT ''入库计划明细ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='plan_pallet_id' AND COALESCE(column_comment,'') <> '海外计划托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN plan_pallet_id BIGINT NULL COMMENT ''海外计划托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_pallet_id' AND COALESCE(column_comment,'') <> '海外实物托盘ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='warehouse_location_id' AND COALESCE(column_comment,'') <> '海外库位ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='pda_task_id' AND COALESCE(column_comment,'') <> '首次扫码PDA任务ID'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN pda_task_id BIGINT NULL COMMENT ''首次扫码PDA任务ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='status' AND COALESCE(column_comment,'') <> '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT ''箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='version' AND COALESCE(column_comment,'') <> '乐观锁版本'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''乐观锁版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='bound_at' AND COALESCE(column_comment,'') <> '绑定托盘时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN bound_at DATETIME(3) NULL COMMENT ''绑定托盘时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='received_at' AND COALESCE(column_comment,'') <> '完成入库时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN received_at DATETIME(3) NULL COMMENT ''完成入库时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_carton' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE ll_inbound_plan_carton MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_loading_plan_version' AND COALESCE(column_comment,'') <> '来源装柜计划版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_loading_plan_version BIGINT COMMENT ''来源装柜计划版本''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_handover_version' AND COALESCE(column_comment,'') <> '交接版本'), 'ALTER TABLE ll_order MODIFY COLUMN source_handover_version BIGINT COMMENT ''交接版本''', 'SELECT 1')); @@ -3577,3 +3637,60 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; + +-- ============================================================================ +-- 2026-08-20 / 028 +-- Phase-4 reverse event identity, unload evidence and SKU/history snapshots. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), + 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', + 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_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_inbound_pda_task' AND column_name='unload_photo_file_ids'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +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_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +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_inbound_pda_task' AND column_name='estimated_empty_return_at'), + 'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''', + 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +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_inventory_balance' AND column_name='sku_code'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code')); +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_inventory_balance' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +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_inventory_balance' AND column_name='product_name_en'), + 'ALTER TABLE ll_inventory_balance MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +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_inbound_stock_receipt' AND column_name='sku_code'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code')); +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_inbound_stock_receipt' AND column_name='product_name_zh'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code')); +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_inbound_stock_receipt' AND column_name='product_name_en'), + 'ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照''', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh')); +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_overseas_procurement_status_history' AND column_name='operation_type'), + 'ALTER TABLE ll_overseas_procurement_status_history MODIFY COLUMN operation_type VARCHAR(32) NOT NULL COMMENT ''操作类型:CREATE、REVIEW或UPDATE''', + 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''UPDATE'' COMMENT ''操作类型:CREATE、REVIEW或UPDATE'' AFTER customer_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_overseas_procurement_status_history' AND column_name='change_detail'), + '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; diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 7e20e5b37..87c520265 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,10 +9,10 @@ test('Jenkins pipelines build finance as an independent service', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - assert.match(controller, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs new file mode 100644 index 000000000..1c1f3b10f --- /dev/null +++ b/scripts/overseas-deployment-contract.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('Jenkins pipelines recognize all overseas deployable modules', async () => { + const controller = await read('Jenkinsfile') + const service = await read('Jenkinsfile.service') + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { + assert.match(controller, new RegExp(`'${module}'`)) + assert.match(service, new RegExp(`'${module}'`)) + } + assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) + assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) + assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) + assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) +}) + +test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { + const map = await read('deploy/service-map.yml') + assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) + assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) + assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) + assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) + assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) +}) + +test('overseas compose is isolated from CN defaults and disables implicit schema execution', async () => { + const compose = await read('deploy/docker-compose.overseas.yml') + assert.match(compose, /^name: firstleg-\$\{DEPLOY_ENV:-test\}-overseas$/m) + assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) + assert.match(compose, /REGION_CODE: OVERSEAS/) + assert.match(compose, /SPRING_SQL_INIT_MODE: never/) + assert.match(compose, /^ gateway-overseas:\s*$/m) + assert.match(compose, /^ lastleg-service:\s*$/m) + assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ event-bridge-service:\s*$/m) + assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) +}) + +test('CN and overseas event bridges have explicit opposite-region consumers', async () => { + const cn = await read('config/nacos/cn/event-bridge-service.yml') + const overseas = await read('config/nacos/overseas/event-bridge-service.yml') + const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` + assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) + assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) + assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) + assert.match(lastleg, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(scripts, /docker-compose\.overseas\.yml/) + assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) + assert.match(scripts, /env_file_value REGION_CODE/) + for (const key of [ + 'NACOS_SERVER_ADDR', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', + 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' + ]) assert.match(scripts, new RegExp(key)) +}) diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index f3e53ff47..c0357deda 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -21,6 +21,7 @@ ## 数据与安全规则 - 平台和商户接口必须分别执行访问域与租户范围校验;客户端传入的 `tenantId` 不能作为唯一授权依据。 +- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。 - 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。 - 内部资源接口必须校验服务令牌;普通用户不得直接调用。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index e8bcfb10f..d6b1004d8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -9,6 +9,7 @@ import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.service.WarehouseApplicationService; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -17,6 +18,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -44,7 +46,7 @@ public class PlatformWarehouseController { @Operation( summary = "Create warehouse / 新建仓库", - description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_CREATE + ". Creates a warehouse, address and service capabilities for the target customer." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), @@ -53,6 +55,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PostMapping + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_CREATE + "')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); @@ -62,7 +65,7 @@ public class PlatformWarehouseController { @Operation( summary = "Update warehouse / 更新仓库", - description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_EDIT + ". Updates the warehouse and replaces its address and capabilities." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), @@ -72,6 +75,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_EDIT + "')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -87,7 +91,7 @@ public class PlatformWarehouseController { @Operation( summary = "List tenant warehouses / 查询租户仓库", - description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns a customer-scoped warehouse page with optional filters." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -95,6 +99,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") }) @GetMapping + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -107,7 +112,7 @@ public class PlatformWarehouseController { @Operation( summary = "Get tenant warehouse details / 查询租户仓库详情", - description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns the warehouse, addresses and service capabilities for the target customer." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), @@ -115,6 +120,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -128,7 +134,7 @@ public class PlatformWarehouseController { @Operation( summary = "Enable or disable warehouse / 启用或停用仓库", - description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_STATUS + ". Changes warehouse status to ACTIVE or INACTIVE." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), @@ -137,6 +143,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_STATUS + "')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -152,7 +159,7 @@ public class PlatformWarehouseController { } private static void requirePlatform(AuthenticatedUser user) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); } } @@ -163,4 +170,3 @@ public class PlatformWarehouseController { } } } - diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7e714246b..be951ca52 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -10,10 +10,13 @@ import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.annotation.Resource; -import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -28,104 +31,114 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController @RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@Tag(name = "Platform overseas warehouse resources") public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; @PostMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_CREATE + "')") + @Operation(summary = "Create a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_CREATE) public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid @RequestBody LocationRequests.SaveRequest request) { - requirePlatform(user, customerId); - return ApiResponse.success(service.createLocation(customerId, warehouseId, request)); + requirePlatform(user); + return ApiResponse.success(service.createLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); } @PutMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_EDIT + "')") + @Operation(summary = "Update a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_EDIT) public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @Valid @RequestBody LocationRequests.SaveRequest request) { - requirePlatform(user, customerId); - service.updateLocation(customerId, warehouseId, id, request); + requirePlatform(user); + service.updateLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); return ApiResponse.success(null); } @GetMapping("/locations") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Page warehouse locations", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) public ApiResponse> locations( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { - requirePlatform(user, customerId); - return ApiResponse.success(service.locations(customerId, warehouseId, query)); + requirePlatform(user); + return ApiResponse.success(service.locations(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); } @GetMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_LIST + "')") + @Operation(summary = "Get a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_LIST) public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - return ApiResponse.success(service.location(customerId, warehouseId, id)); + requirePlatform(user); + return ApiResponse.success(service.location(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); } @DeleteMapping("/locations/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.LOCATION_DELETE + "')") + @Operation(summary = "Delete a warehouse location", description = "Required authority: " + OverseasWarehousePermissions.LOCATION_DELETE) public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - service.deleteLocation(customerId, warehouseId, id); + requirePlatform(user); + service.deleteLocation(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); return ApiResponse.success(null); } @PostMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid @RequestBody PalletRequests.SaveRequest request) { - requirePlatform(user, customerId); - return ApiResponse.success(service.createPallet(customerId, warehouseId, request)); + requirePlatform(user); + return ApiResponse.success(service.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, request)); } @PutMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_EDIT + "')") + @Operation(summary = "Update a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_EDIT) public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @Valid @RequestBody PalletRequests.SaveRequest request) { - requirePlatform(user, customerId); - service.updatePallet(customerId, warehouseId, id, request); + requirePlatform(user); + service.updatePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id, request); return ApiResponse.success(null); } @GetMapping("/pallets") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Page warehouse pallets", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) public ApiResponse> pallets( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { - requirePlatform(user, customerId); - return ApiResponse.success(service.pallets(customerId, warehouseId, query)); + requirePlatform(user); + return ApiResponse.success(service.pallets(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, query)); } @GetMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_LIST + "')") + @Operation(summary = "Get a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_LIST) public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - return ApiResponse.success(service.pallet(customerId, warehouseId, id)); + requirePlatform(user); + return ApiResponse.success(service.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id)); } @DeleteMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_DELETE + "')") + @Operation(summary = "Delete a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_DELETE) public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { - requirePlatform(user, customerId); - service.deletePallet(customerId, warehouseId, id); + requirePlatform(user); + service.deletePallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, warehouseId, id); return ApiResponse.success(null); } - private static void requirePlatform(AuthenticatedUser user, Long customerId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); } - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); - } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java index 03933ca81..a2fb28afa 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceLockRepository.java @@ -7,11 +7,13 @@ import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; import com.baiyue.warehouse.mapper.WarehouseLocationMapper; import com.baiyue.warehouse.mapper.WarehousePalletMapper; import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper; +import com.baiyue.warehouse.service.WarehouseResourceService; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @Repository public class WarehouseResourceLockRepository { + private static final long PUBLIC_RESOURCE_SCOPE = WarehouseResourceService.PUBLIC_RESOURCE_SCOPE; @Resource private WarehouseResourceLockMapper locks; @Resource private WarehousePalletMapper pallets; @Resource private WarehouseLocationMapper locations; @@ -32,58 +34,58 @@ public class WarehouseResourceLockRepository { public WarehousePalletEntity findPallet(long customerId, long palletId) { return pallets.selectOne(new LambdaQueryWrapper() - .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehousePalletEntity::getId, palletId)); } public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) { return pallets.selectOne(new LambdaQueryWrapper() - .eq(WarehousePalletEntity::getCustomerId, customerId) + .eq(WarehousePalletEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehousePalletEntity::getWarehouseId, warehouseId) .eq(WarehousePalletEntity::getId, palletId)); } public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long locationId) { return locations.selectOne(new LambdaQueryWrapper() - .eq(WarehouseLocationEntity::getCustomerId, customerId) + .eq(WarehouseLocationEntity::getCustomerId, PUBLIC_RESOURCE_SCOPE) .eq(WarehouseLocationEntity::getWarehouseId, warehouseId) .eq(WarehouseLocationEntity::getId, locationId)); } public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) { - return locks.occupyPallet(customerId, warehouseId, palletId, locationId); + return locks.occupyPallet(PUBLIC_RESOURCE_SCOPE, warehouseId, palletId, locationId); } public int occupyLocation(long customerId, long warehouseId, long locationId) { - return locks.occupyLocation(customerId, warehouseId, locationId); + return locks.occupyLocation(PUBLIC_RESOURCE_SCOPE, warehouseId, locationId); } public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); } public int releasePallet(WarehouseResourceLockEntity lock) { - return locks.releasePallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + return locks.releasePallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) { - return locks.releasePalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + return locks.releasePalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int scrapPallet(WarehouseResourceLockEntity lock) { - return locks.scrapPallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); + return locks.scrapPallet(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int scrapPalletWithoutLock(long customerId, long palletId) { - return locks.scrapPalletWithoutLock(customerId, palletId); + return locks.scrapPalletWithoutLock(PUBLIC_RESOURCE_SCOPE, palletId); } public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) { - return locks.scrapPalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(), + return locks.scrapPalletAtVirtualLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId()); } public int releaseLocation(WarehouseResourceLockEntity lock) { - return locks.releaseLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId()); + return locks.releaseLocation(PUBLIC_RESOURCE_SCOPE, lock.getWarehouseId(), lock.getLocationId()); } public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index 7c02c7dbf..cdd5dc0f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -45,6 +45,10 @@ public class WarehouseResourceRepository { .eq(WarehouseEntity::getId, warehouseId)); } + public WarehouseEntity findWarehouseById(long warehouseId) { + return warehouses.selectById(warehouseId); + } + public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) { return locations.selectOne(new LambdaQueryWrapper() .eq(WarehouseLocationEntity::getCustomerId, customerId) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java new file mode 100644 index 000000000..acac5005f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.security; + +/** Canonical Admin authorities for overseas warehouse resources. */ +public final class OverseasWarehousePermissions { + public static final String WAREHOUSE_LIST = "platform:overseas-warehouse:warehouse:list"; + public static final String WAREHOUSE_CREATE = "platform:overseas-warehouse:warehouse:create"; + public static final String WAREHOUSE_EDIT = "platform:overseas-warehouse:warehouse:edit"; + public static final String WAREHOUSE_STATUS = "platform:overseas-warehouse:warehouse:status"; + public static final String LOCATION_LIST = "platform:overseas-warehouse:location:list"; + public static final String LOCATION_CREATE = "platform:overseas-warehouse:location:create"; + public static final String LOCATION_EDIT = "platform:overseas-warehouse:location:edit"; + public static final String LOCATION_DELETE = "platform:overseas-warehouse:location:delete"; + public static final String PALLET_LIST = "platform:overseas-warehouse:pallet:list"; + public static final String PALLET_CREATE = "platform:overseas-warehouse:pallet:create"; + public static final String PALLET_EDIT = "platform:overseas-warehouse:pallet:edit"; + public static final String PALLET_DELETE = "platform:overseas-warehouse:pallet:delete"; + + private OverseasWarehousePermissions() { } +} 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 b71345643..00695b535 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 @@ -22,6 +22,7 @@ import org.springframework.util.StringUtils; @Service public class WarehouseResourceService { + public static final long PUBLIC_RESOURCE_SCOPE = 0L; private static final Set LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE"); private static final Set PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED"); @@ -187,13 +188,20 @@ public class WarehouseResourceService { } private void requireWarehouse(long customerId, long warehouseId) { + if (customerId == PUBLIC_RESOURCE_SCOPE) { + if (repository.findWarehouseById(warehouseId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "Warehouse not found"); + } + return; + } if (!repository.warehouseOwned(customerId, warehouseId)) { throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在"); } } private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) { - WarehouseEntity warehouse = repository.findWarehouse(customerId, warehouseId); + WarehouseEntity warehouse = customerId == PUBLIC_RESOURCE_SCOPE + ? repository.findWarehouseById(warehouseId) : repository.findWarehouse(customerId, warehouseId); if (warehouse == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found"); } diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 1980faedf..212a72cae 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -18,17 +18,17 @@ spring: - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} username: ${NACOS_USERNAME:nacos} password: ${NACOS_PASSWORD:nacos} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} namespace: ${NACOS_NAMESPACE:public} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858} + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:public} datasource: driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java index 4c1f680e1..1da6a1cb5 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourceControllerTest.java @@ -34,20 +34,29 @@ class WarehouseResourceControllerTest { PlatformWarehouseResourceController controller = platformController(); assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"), - 10002L, 20001L, 30001L)) + 20001L, 30001L)) .isInstanceOf(BizException.class) .hasMessageContaining("平台端"); } @Test - void platformApiUsesExplicitTargetTenant() { + void platformApiUsesPublicResourceScope() { PlatformWarehouseResourceController controller = platformController(); WarehouseLocationEntity expected = new WarehouseLocationEntity(); - when(service.location(10002L, 20001L, 30001L)).thenReturn(expected); + when(service.location(0L, 20001L, 30001L)).thenReturn(expected); - controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L); + controller.location(user(0L, "PLATFORM"), 20001L, 30001L); - verify(service).location(10002L, 20001L, 30001L); + verify(service).location(0L, 20001L, 30001L); + } + + @Test + void platformApiRejectsPlatformAppWithCustomerScope() { + PlatformWarehouseResourceController controller = platformController(); + + assertThatThrownBy(() -> controller.location(user(10001L, "PLATFORM"), 20001L, 30001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("平台端"); } private static AuthenticatedUser user(long customerId, String appCode) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java new file mode 100644 index 000000000..65588f077 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.controller; + +import io.swagger.v3.oas.annotations.Operation; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WarehouseResourcePermissionContractTest { + private static final String PREFIX = "platform:overseas-warehouse:"; + + @Test + void locationAndPalletEndpointsUseOnlyCanonicalAuthorities() { + Map methods = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PreAuthorize.class)) + .collect(Collectors.toMap(Method::getName, method -> method)); + + assertPermission(methods.get("locations"), PREFIX + "location:list"); + assertPermission(methods.get("location"), PREFIX + "location:list"); + assertPermission(methods.get("createLocation"), PREFIX + "location:create"); + assertPermission(methods.get("updateLocation"), PREFIX + "location:edit"); + assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); + assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); + assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); + assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); + assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); + } + + private static void assertPermission(Method method, String authority) { + PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class); + Operation operation = method.getAnnotation(Operation.class); + assertEquals("hasAuthority('" + authority + "')", preAuthorize.value()); + assertEquals("Required authority: " + authority, operation.description()); + } +} 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 778edcddc..e52b8fc79 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 @@ -67,6 +67,23 @@ class WarehouseResourceServiceTest { assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); } + @Test + void platformPalletUsesPublicScopeAgainstAnyExistingWarehouse() { + WarehouseEntity warehouse = warehouse(); + when(repository.findWarehouseById(WAREHOUSE)).thenReturn(warehouse); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30001L); + when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location); + + service.createPallet(0L, WAREHOUSE, new PalletRequests.SaveRequest( + "PLT-PUBLIC", "STANDARD", null, BigDecimal.ONE, "IDLE", 30001L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getCustomerId()).isZero(); + assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE); + } + @Test void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() { when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false); From a38396ce3fd28fbfd28d7e605907e08b1e278a9b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 18:06:07 +0800 Subject: [PATCH 022/231] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/InboundPlanController.java | 26 ++++++ .../dto/BatchFbaAppointmentRequest.java | 1 + .../dto/FbaAppointmentResponse.java | 1 + .../dto/InboundPlanListResponse.java | 30 +++++++ .../inboundplan/dto/InboundPlanRequests.java | 31 +++++++ .../dto/UpdateFbaAppointmentRequest.java | 16 ++++ .../entity/InboundFbaAppointment.java | 1 + .../mapper/InboundPlanQueryMapper.java | 53 ++++++++++++ .../service/InboundFbaAppointmentService.java | 32 +++++-- .../service/InboundPlanService.java | 14 ++++ .../query/dto/InventoryBalanceView.java | 5 ++ .../query/dto/InventoryLedgerView.java | 1 + .../query/dto/InventoryQueryRequests.java | 6 ++ .../query/mapper/InventoryQueryMapper.java | 28 +++++++ .../query/model/InventoryLocationFact.java | 10 +++ .../query/model/InventorySkuFact.java | 10 +++ .../query/service/InventoryQueryService.java | 43 ++++++++-- .../src/main/resources/db/lastleg.sql | 1 + ...LastlegPlatformPermissionContractTest.java | 2 + .../InboundPlanSchemaContractTest.java | 6 +- .../controller/InboundPlanControllerTest.java | 68 +++++++++++++++ .../InboundFbaAppointmentServiceTest.java | 50 ++++++++++- .../service/InboundPlanPaginationTest.java | 83 +++++++++++++++++++ .../InventoryQueryControllerTest.java | 2 +- .../service/InventoryQueryServiceTest.java | 23 ++++- scripts/db/incremental.sql | 10 +++ 26 files changed, 532 insertions(+), 21 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryLocationFact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventorySkuFact.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 949799eb2..ff5f36721 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -1,12 +1,16 @@ package com.baiyue.lastleg.inboundplan.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.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import io.swagger.v3.oas.annotations.Operation; @@ -26,6 +30,17 @@ public class InboundPlanController { @Resource private InboundPlanService service; @Resource private InboundFbaAppointmentService appointmentService; + /** 按入仓计划明细分页查询可预约的入库订单行。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')") + @Operation(summary = "分页查询入库订单") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, + @Valid InboundPlanRequests.Query query) { + return ApiResponse.success(service.page(scope(user, customerId), query)); + } + @PostMapping("/convert") @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')") /** 将入仓单转换为入仓计划。 */ @@ -78,6 +93,17 @@ public class InboundPlanController { return ApiResponse.success(appointmentService.detail(scope(user, customerId), appointmentNo)); } + @PutMapping("/fba-appointments/{appointmentNo}") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')") + @Operation(summary = "编辑 FBA 预约") + public ApiResponse updateFbaAppointment( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String appointmentNo, + @Valid @RequestBody UpdateFbaAppointmentRequest request) { + return ApiResponse.success(appointmentService.update(scope(user, request.customerId()), + appointmentNo, request)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java index 1572cb552..7547e0bc3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java @@ -13,6 +13,7 @@ public record BatchFbaAppointmentRequest( @NotEmpty @Size(max = 500) List<@NotNull Long> lineIds, @NotBlank @Size(max = 64) String appointmentNo, @NotNull LocalDateTime appointmentAt, + @NotNull LocalDateTime expectedLoadingAt, @NotBlank @Size(max = 32) String appointmentStatus, @Size(max = 1000) String appointmentRemark ) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java index acf32635b..4c6c31d8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java @@ -8,6 +8,7 @@ public record FbaAppointmentResponse( String customerId, String appointmentNo, LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, String appointmentStatus, String appointmentRemark, String fbaWarehouseCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java new file mode 100644 index 000000000..5a4e1c6a5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record InboundPlanListResponse( + Long lineId, + Long planId, + String planNo, + String customerId, + Long orderId, + String inboundCode, + String orderType, + String warehouseId, + String warehouseCode, + String warehouseName, + String fbaWarehouseCode, + String customerName, + Integer packageCount, + BigDecimal volumeCbm, + Integer palletCount, + String status, + String appointmentNo, + String appointmentStatus, + LocalDateTime appointmentAt, + LocalDateTime expectedLoadingAt, + String dispatchStatus, + LocalDateTime createdAt +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java new file mode 100644 index 000000000..d1c3ad97d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public final class InboundPlanRequests { + private InboundPlanRequests() { + } + + public record Query( + @Min(1) long page, + @Min(1) @Max(200) long size, + String planNo, + String inboundCode, + String status, + String warehouse, + String fbaWarehouseCode, + @Pattern(regexp = "UNAPPOINTED|PENDING|SUCCESS|FAILED|CANCELLED") String appointmentStatus, + @Pattern(regexp = "UNSCHEDULED|SCHEDULED") String dispatchStatus, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public Query { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java new file mode 100644 index 000000000..6f7076fe5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record UpdateFbaAppointmentRequest( + String customerId, + @NotNull LocalDateTime appointmentAt, + @NotNull LocalDateTime expectedLoadingAt, + @NotBlank @Size(max = 32) String appointmentStatus, + @Size(max = 1000) String appointmentRemark +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java index 20f34801b..fc075215b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java @@ -15,6 +15,7 @@ public class InboundFbaAppointment { private String customerId; private String appointmentNo; private LocalDateTime appointmentAt; + private LocalDateTime expectedLoadingAt; private String appointmentStatus; private String appointmentRemark; private String fbaWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java new file mode 100644 index 000000000..a1331ac55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.inboundplan.mapper; + +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface InboundPlanQueryMapper { + String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + + " JOIN ll_inbound_plan p ON p.id=l.plan_id AND p.customer_id=#{customerId}" + + " JOIN ll_order o ON o.id=l.order_id AND o.customer_id=#{customerId}" + + " LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.line_id=l.id" + + " AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2" + + " JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id" + + " WHERE ai2.line_id=l.id AND a2.customer_id=#{customerId}" + + " ORDER BY a2.created_at DESC,ai2.id DESC LIMIT 1)" + + " LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id AND a.customer_id=#{customerId}" + + " WHERE 1=1" + + " AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')" + + " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + + " AND p.status=#{q.status}" + + " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + + " AND ai.id IS NULL" + + " AND a.appointment_status=#{q.appointmentStatus}" + + " AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND p.created_at >=#{q.createdFrom}" + + " AND p.created_at <=#{q.createdTo}"; + + @Select("") + List rows(@Param("customerId") String customerId, + @Param("q") InboundPlanRequests.Query query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long count(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 6045f4a46..b06b8a05a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; +import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment; import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; @@ -44,7 +45,7 @@ public class InboundFbaAppointmentService { InboundFbaAppointment existing = find(customerId, appointmentNo); if (existing != null) { - return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); } List lines = queryMapper.lockLines(customerId, lineIds); @@ -57,7 +58,7 @@ public class InboundFbaAppointmentService { // The row locks serialize concurrent submissions selecting the same inbound lines. existing = find(customerId, appointmentNo); if (existing != null) { - return verifyReplay(existing, lineIds, request.appointmentAt(), status, remark); + return verifyReplay(existing, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); } LocalDateTime now = LocalDateTime.now(); @@ -66,6 +67,7 @@ public class InboundFbaAppointmentService { appointment.setCustomerId(customerId); appointment.setAppointmentNo(appointmentNo); appointment.setAppointmentAt(request.appointmentAt()); + appointment.setExpectedLoadingAt(request.expectedLoadingAt()); appointment.setAppointmentStatus(status); appointment.setAppointmentRemark(remark); appointment.setFbaWarehouseCode(fbaWarehouseCode); @@ -78,7 +80,7 @@ public class InboundFbaAppointmentService { } catch (DuplicateKeyException exception) { InboundFbaAppointment concurrent = find(customerId, appointmentNo); if (concurrent != null) { - return verifyReplay(concurrent, lineIds, request.appointmentAt(), status, remark); + return verifyReplay(concurrent, lineIds, request.appointmentAt(), request.expectedLoadingAt(), status, remark); } throw exception; } @@ -108,12 +110,31 @@ public class InboundFbaAppointmentService { return response(appointment, items(appointment.getId())); } + @Transactional + public FbaAppointmentResponse update(String customerId, String appointmentNo, + UpdateFbaAppointmentRequest request) { + InboundFbaAppointment appointment = find(customerId, + requiredTrimmed(appointmentNo, "Appointment number is required")); + if (appointment == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "FBA appointment not found"); + } + appointment.setAppointmentAt(request.appointmentAt()); + appointment.setExpectedLoadingAt(request.expectedLoadingAt()); + appointment.setAppointmentStatus(normalizeStatus(request.appointmentStatus())); + appointment.setAppointmentRemark(trimToNull(request.appointmentRemark())); + appointment.setUpdatedAt(LocalDateTime.now()); + appointmentMapper.updateById(appointment); + return response(appointment, items(appointment.getId())); + } + private FbaAppointmentResponse verifyReplay(InboundFbaAppointment existing, List requestedLineIds, - LocalDateTime appointmentAt, String status, String remark) { + LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, + String status, String remark) { List existingItems = items(existing.getId()); Set existingLineIds = existingItems.stream() .map(InboundFbaAppointmentItem::getLineId).collect(java.util.stream.Collectors.toSet()); if (!Objects.equals(existing.getAppointmentAt(), appointmentAt) + || !Objects.equals(existing.getExpectedLoadingAt(), expectedLoadingAt) || !Objects.equals(existing.getAppointmentStatus(), status) || !Objects.equals(existing.getAppointmentRemark(), remark) || existingLineIds.size() != requestedLineIds.size() @@ -149,7 +170,8 @@ public class InboundFbaAppointmentService { private FbaAppointmentResponse response(InboundFbaAppointment appointment, List items) { return new FbaAppointmentResponse(appointment.getId(), appointment.getCustomerId(), - appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getAppointmentStatus(), + appointment.getAppointmentNo(), appointment.getAppointmentAt(), appointment.getExpectedLoadingAt(), + appointment.getAppointmentStatus(), appointment.getAppointmentRemark(), appointment.getFbaWarehouseCode(), appointment.getSelectedCount(), appointment.getCreatedBy(), appointment.getCreatedAt(), appointment.getUpdatedAt(), items.stream().map(item -> new FbaAppointmentResponse.Item(item.getId(), item.getPlanId(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 588cd634d..f25834a99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -2,6 +2,9 @@ package com.baiyue.lastleg.inboundplan.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -13,6 +16,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -35,6 +39,16 @@ public class InboundPlanService { @Resource private InboundPlanPalletMapper palletMapper; @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; + @Resource private InboundPlanQueryMapper queryMapper; + + @Transactional(readOnly = true) + public PageResult page(String customerId, InboundPlanRequests.Query query) { + long total = queryMapper.count(customerId, query); + List records = total == 0 ? List.of() : queryMapper.rows( + customerId, query, (query.page() - 1) * query.size(), query.size()); + long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java index 64fd75cb9..55057a983 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.query.dto; import java.time.LocalDateTime; +import java.util.List; public record InventoryBalanceView( Long id, @@ -15,6 +16,10 @@ public record InventoryBalanceView( Long inboundOrderId, String inboundOrderNo, String inboundCode, + List skuCodes, + List productNames, + List warehouseLocationIds, + List warehouseLocationCodes, long totalPackages, long availablePackages, long holdPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java index 00b602cbc..7f60e2f0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java @@ -13,6 +13,7 @@ public record InventoryLedgerView( String palletNo, String batchNo, String warehouseLocationCode, + String skuCode, String businessType, String businessOrderNo, String outboundOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java index 0783e3aef..1ab5b2fed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java @@ -19,6 +19,10 @@ public final class InventoryQueryRequests { String inboundOrderNo, String inboundCode, String businessOrderNo, + String skuCode, + String productName, + String batchNo, + String warehouseLocationCode, String inventoryStatus ) { public BalanceQuery { @@ -42,6 +46,8 @@ public final class InventoryQueryRequests { String outboundOrderNo, String palletNo, String batchNo, + String skuCode, + String warehouseLocationCode, LocalDateTime occurredFrom, LocalDateTime occurredTo ) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index ab7344b37..9ac576049 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -5,6 +5,8 @@ import com.baiyue.lastleg.inventory.entity.InventoryLedger; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; import com.baiyue.lastleg.inventory.query.model.InventoryAggregateProjection; +import com.baiyue.lastleg.inventory.query.model.InventoryLocationFact; +import com.baiyue.lastleg.inventory.query.model.InventorySkuFact; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -19,6 +21,10 @@ public interface InventoryQueryMapper { " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=ll_inventory_balance.inbound_order_id AND d.sku_code LIKE CONCAT('%',#{q.skuCode},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=ll_inventory_balance.inbound_order_id AND (d.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR d.product_name_en LIKE CONCAT('%',#{q.productName},'%')))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=#{customerId} AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + "" + " AND available_packages > 0" + " AND hold_packages > 0" + @@ -37,6 +43,10 @@ public interface InventoryQueryMapper { " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=#{customerId} AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=b.inbound_order_id AND d.sku_code LIKE CONCAT('%',#{q.skuCode},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_order_detail d WHERE d.order_id=b.inbound_order_id AND (d.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR d.product_name_en LIKE CONCAT('%',#{q.productName},'%')))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=#{customerId} AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + "" + " AND b.available_packages > 0" + " AND b.hold_packages > 0" + @@ -58,6 +68,22 @@ public interface InventoryQueryMapper { long countBalances(@Param("customerId") String customerId, @Param("q") InventoryQueryRequests.BalanceQuery query); + @Select("") + List skuFacts(@Param("customerId") String customerId, + @Param("balanceIds") List balanceIds); + + @Select("") + List locationFacts(@Param("customerId") String customerId, + @Param("balanceIds") List balanceIds); + @Select("") + List findCustomerScopesByPlanIds(@Param("planIds") List planIds); + + @Select("") + List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); + @Select("") List available(@Param("customerId") String customerId, + @Param("planIds") List planIds, @Param("orderNo") String orderNo, @Param("fbaWarehouseCode") String fbaWarehouseCode, @Param("sourceStage") String sourceStage); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index bade8c38a..461008164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -50,13 +50,26 @@ public class DispatchService { return customerIds.isEmpty() ? null : customerIds.get(0); } - public List candidates(String customerId, String orderNo, + public String resolveCustomerScopeForPlans(List planIds) { + return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), + "Selected inbound plans"); + } + + public String resolveCustomerScopeForPallets(List planPalletIds) { + return requireSingleCustomerScope( + candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")), + "Selected pallets"); + } + + public List candidates(String customerId, List planIds, String orderNo, String fbaWarehouseCode, String sourceStage) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); } if (!StringUtils.hasText(customerId)) return List.of(); - return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); + return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage)); } @Transactional @@ -278,4 +291,25 @@ public class DispatchService { private static String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + + private static List normalizeIds(List ids, String label) { + if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty"); + } + List normalized = ids.stream().distinct().sorted().toList(); + if (normalized.size() > 500) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items"); + } + return normalized; + } + + private static String requireSingleCustomerScope(List customerIds, String source) { + if (customerIds == null || customerIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found"); + } + if (customerIds.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, source + " must belong to exactly one customer"); + } + return customerIds.get(0); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 712d2b829..99bf49851 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -17,10 +17,13 @@ public final class LastlegOrderRequests { @Min(1) long page, @Min(1) @Max(200) long size, String orderNo, + String orderType, String inboundCode, String sourceOrderNo, String containerNo, + String currentStatus, String status, + String warehouse, String transferWarehouseCode, Boolean abnormal, Boolean detained, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 0a8719a4c..f294b6fc7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -33,7 +33,7 @@ public class InboundPlanController { /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @@ -43,7 +43,7 @@ public class InboundPlanController { } @PostMapping("/convert") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") /** 将入仓单转换为入仓计划。 */ @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @@ -72,9 +72,9 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") /** 为选中的入仓明细创建 FBA 预约。 */ - @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) public ApiResponse batchFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody BatchFbaAppointmentRequest request) { @@ -95,8 +95,8 @@ public class InboundPlanController { } @PutMapping("/fba-appointments/{appointmentNo}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") - @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") + @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) public ApiResponse updateFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String appointmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 910f94a98..5ac51600c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -8,6 +8,10 @@ public final class OverseasPermissions { public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; + public static final String INBOUND_ORDER_FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; + public static final String INBOUND_ORDER_SMART_DISPATCH = + "platform:overseas-warehouse:inbound-order:smart-dispatch"; public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 9be1c0f3d..3ca6411e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -123,13 +123,20 @@ public class LastlegOrderService { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } + String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) - .eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status()) + .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) + .and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper + .like(LastlegOrder::getOverseasWarehouseId, query.warehouse()) + .or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseName, query.warehouse())) .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, query.transferWarehouseCode()) .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) 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 f6cea8506..bd48a2959 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,27 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/platform/v1/last-leg/orders: + get: + summary: 分页查询尾程订单 + description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。 + 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: orderNo, in: query, schema: { type: string } } + - { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP] } } + - { name: currentStatus, in: query, schema: { type: string } } + - { name: inboundCode, in: query, schema: { type: string } } + - { name: sourceOrderNo, in: query, schema: { type: string } } + - { name: containerNo, in: query, schema: { type: string } } + - { name: warehouse, in: query, schema: { type: string } } + - { name: transferWarehouseCode, in: query, schema: { type: string } } + - { name: abnormal, in: query, schema: { type: boolean } } + - { name: detained, in: query, schema: { type: boolean } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 @@ -35,6 +56,13 @@ paths: description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。 security: [{ bearerAuth: [] }] parameters: + - name: planIds + in: query + required: true + description: 已选入库计划 ID,逗号分隔;服务端据此解析并校验唯一客户范围。 + schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } } + style: form + explode: false - { name: orderNo, in: query, schema: { type: string } } - { name: fbaWarehouseCode, in: query, schema: { type: string } } - { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } } @@ -45,7 +73,7 @@ paths: /api/platform/v1/last-leg/dispatches/pre-arrange: post: summary: 创建智能预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:edit;服务端解析唯一客户范围,锁定同一海外仓的所选托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 security: [{ bearerAuth: [] }] requestBody: required: true 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 ced14630f..e2975697a 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 @@ -25,7 +25,7 @@ class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void platformEndpointRejectsMerchantIdentity() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java index 0e1a2b7aa..c21728b09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final MerchantLastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantListUsesOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 660f94f4c..2d88558b0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -17,9 +17,11 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list")); - assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:edit")); + assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); + assertTrue(yaml.contains("name: planIds")); + assertTrue(yaml.contains("required: true")); String dispatchContract = yaml.substring(0, yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:")); assertFalse(dispatchContract.contains("name: customerId")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 32cd2bc6f..3b198b269 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -31,20 +31,20 @@ class SmartDispatchControllerTest { } @Test - void candidatesResolveSingleCustomerScopeWithoutRequestParameter() { - when(service.resolveSingleCustomerScope()).thenReturn("100"); - when(service.candidates("100", "LMO-1", "ONT8", "PLAN")).thenReturn(List.of()); + void candidatesResolveCustomerScopeFromSelectedPlans() { + when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100"); + when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN")).thenReturn(List.of()); - controller.candidates(platform(), "LMO-1", "ONT8", "PLAN"); + controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN"); - verify(service).candidates("100", "LMO-1", "ONT8", "PLAN"); + verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN"); } @Test void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); - when(service.resolveSingleCustomerScope()).thenReturn("100"); + when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); controller.preArrange(platform(), request); @@ -52,20 +52,20 @@ class SmartDispatchControllerTest { } @Test - void endpointsReuseInboundOrderListAndEditAuthorities() throws Exception { + void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception { PreAuthorize candidates = SmartDispatchController.class - .getMethod("candidates", AuthenticatedUser.class, String.class, String.class, String.class) + .getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class) .getAnnotation(PreAuthorize.class); PreAuthorize preArrange = SmartDispatchController.class .getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class) .getAnnotation(PreAuthorize.class); assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); - assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_EDIT); + assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); } private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_EDIT)); + Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index e40a65ff4..ff778910f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -82,8 +82,28 @@ class DispatchServiceTest { @Test void invalidCandidateStageIsRejected() { - assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); - verify(candidates, never()).available(any(), any(), any(), any()); + assertThrows(ResponseStatusException.class, + () -> service.candidates("100", List.of(1L), null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any(), any()); + } + + @Test + void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); + when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100")); + + assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L))); + assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L))); + } + + @Test + void rejectsSelectedPlansThatSpanCustomers() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.resolveCustomerScopeForPlans(List.of(1L, 2L))); + + assertEquals(409, exception.getStatusCode().value()); } @Test @@ -99,8 +119,8 @@ class DispatchServiceTest { @Test void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { - assertEquals(List.of(), service.candidates(null, null, null, null)); - verify(candidates, never()).available(any(), any(), any(), any()); + assertEquals(List.of(), service.candidates(null, null, null, null, null)); + verify(candidates, never()).available(any(), any(), any(), any(), any()); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.preArrange(null, "7", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 8d3f3b085..d73111a9b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -13,6 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class InboundPlanPermissionContractTest { private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + private static final String FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; @Test void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { @@ -25,4 +27,18 @@ class InboundPlanPermissionContractTest { assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); } } + + @Test + void fbaAppointmentWritesUseDedicatedAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"batchFbaAppointment", "updateFbaAppointment"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + FBA_APPOINTMENT + "')", + method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + FBA_APPOINTMENT, + method.getAnnotation(Operation.class).description()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 1d36bbf6d..6d691750a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -104,6 +104,23 @@ class LastlegOrderServiceTest { verify(orders, never()).updateById(any(LastlegOrder.class)); } + @Test + void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { + var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null, + "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); + resultPage.setTotal(0); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + + service.page(null, query); + + ArgumentCaptor> page = + ArgumentCaptor.forClass(com.baomidou.mybatisplus.extension.plugins.pagination.Page.class); + verify(orders).selectPage(page.capture(), any()); + assertEquals(3, page.getValue().getCurrent()); + assertEquals(25, page.getValue().getSize()); + } + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { LastlegOrder order = new LastlegOrder(); order.setId(id); 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 3ee2c40f6..c482c97b4 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 @@ -9,6 +9,25 @@ class LoadingCompletedConsumerTest { @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 rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ + var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); + var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); + var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( + new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), + new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); + EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); + + assertEquals(1,service.consume(event)); + verify(orders,times(1)).insert(any(LastlegOrder.class)); + ArgumentCaptor detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details,times(2)).insert(detailCaptor.capture()); + assertEquals(Map.of("SKU-A",2,"SKU-B",3),detailCaptor.getAllValues().stream().collect(java.util.stream.Collectors.toMap(LastlegOrderDetail::getSkuCode,LastlegOrderDetail::getPackageCount))); + ArgumentCaptor receiveCaptor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); + verify(dropshipOrders).receive(receiveCaptor.capture()); + assertEquals(2,receiveCaptor.getValue().pallets().get(0).skuPackages().size()); + assertEquals(Map.of("SKU-A",2L,"SKU-B",3L),receiveCaptor.getValue().pallets().get(0).skuPackages().stream().collect(java.util.stream.Collectors.toMap(DropshipRequests.SkuPackages::skuCode,DropshipRequests.SkuPackages::packageCount))); + } @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); @@ -40,4 +59,5 @@ class LoadingCompletedConsumerTest { verify(orders,never()).insert(any(LastlegOrder.class)); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } } From 0e7df9052a14242bd3e5cb50842795814cbae047 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 16:04:16 +0800 Subject: [PATCH 034/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/firstleg-service.yml | 8 + config/nacos/cn/integration-service.yml | 36 +++ config/nacos/local/firstleg-service.yml | 6 + config/nacos/local/integration-service.yml | 36 +++ deploy/docker-compose.yml | 36 +++ deploy/sql/头程数据库统一增量-MySQL8.sql | 90 +++++-- firstleg/AGENTS.md | 4 +- .../CarrierScheduleIntegrationClient.java | 4 + ...nternalCarrierScheduleQueryController.java | 48 ---- ...latformCarrierScheduleQueryController.java | 13 ++ .../domain/CarrierScheduleExceptionCode.java | 20 ++ .../domain/CarrierScheduleNode.java | 12 +- .../dto/CarrierScheduleQueryDtos.java | 39 +++- .../CarrierScheduleExceptionFactEntity.java | 31 +++ .../entity/CarrierScheduleNodeFactEntity.java | 1 + .../CarrierScheduleExceptionFactMapper.java | 23 ++ .../mapper/CarrierScheduleNodeFactMapper.java | 4 +- .../mapper/CarrierScheduleQueryMapper.java | 4 +- .../CarrierScheduleQueryRepository.java | 12 + .../CarrierScheduleAdapterRegistry.java | 52 +++-- .../CarrierScheduleQueryScheduler.java | 9 + .../service/CarrierScheduleQueryService.java | 137 ++++++++--- firstleg/src/main/resources/application.yml | 8 + .../phase3-carrier-schedule-query-schema.sql | 62 +++-- .../openapi/carrier-schedule-internal-v1.yaml | 23 -- .../openapi/carrier-schedule-query-v1.yaml | 140 ++++++++++- ...rrierScheduleQueryOpenApiContractTest.java | 13 +- ...erScheduleQueryControllerContractTest.java | 5 +- .../dto/CarrierScheduleQueryDtosTest.java | 14 ++ .../CarrierScheduleQuerySqlContractTest.java | 21 +- .../CarrierScheduleAdapterRegistryTest.java | 14 +- .../CarrierScheduleQuerySchedulerTest.java | 8 +- .../CarrierScheduleQueryServiceTest.java | 174 +++++++++++++- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- integration-service/AGENTS.md | 2 +- .../config/IntegrationProperties.java | 133 ++++++++++- .../domain/CarrierScheduleExceptionCode.java | 15 ++ .../domain/CarrierScheduleStandardNode.java | 8 +- .../integration/dto/CarrierScheduleDtos.java | 6 + ...mpliantCarrierScheduleProviderAdapter.java | 7 +- ...ergreenCarrierScheduleProviderAdapter.java | 202 ++++++++++++++++ .../provider/EvergreenTrackingClient.java | 151 ++++++++++++ .../HedeCarrierScheduleProviderAdapter.java | 161 +++++++++++++ .../provider/HedeTrackingClient.java | 123 ++++++++++ .../MatsonCarrierScheduleProviderAdapter.java | 220 ++++++++++++++++++ .../provider/MatsonTrackingClient.java | 172 ++++++++++++++ .../SmlCarrierScheduleProviderAdapter.java | 154 ++++++++++++ .../provider/SmlTrackingClient.java | 165 +++++++++++++ .../ZimCarrierScheduleProviderAdapter.java | 199 ++++++++++++++++ .../provider/ZimTrackingClient.java | 207 ++++++++++++++++ .../CarrierScheduleProviderService.java | 12 +- .../src/main/resources/application.yml | 36 +++ .../openapi/carrier-schedule-provider-v1.yaml | 20 +- ...eenCarrierScheduleProviderAdapterTest.java | 74 ++++++ .../provider/EvergreenTrackingClientTest.java | 91 ++++++++ ...edeCarrierScheduleProviderAdapterTest.java | 95 ++++++++ .../provider/HedeTrackingClientTest.java | 59 +++++ ...sonCarrierScheduleProviderAdapterTest.java | 109 +++++++++ .../provider/MatsonTrackingClientTest.java | 73 ++++++ ...SmlCarrierScheduleProviderAdapterTest.java | 71 ++++++ .../provider/SmlTrackingClientTest.java | 147 ++++++++++++ ...ZimCarrierScheduleProviderAdapterTest.java | 106 +++++++++ .../provider/ZimTrackingClientTest.java | 152 ++++++++++++ .../CarrierScheduleProviderServiceTest.java | 20 +- .../ocr/WeightOcrBatchServiceTest.java | 2 +- .../phase3-carrier-schedule-queries.mjs | 54 ++--- .../phase4-carrier-nodes-contract.test.mjs | 47 ++-- .../phase4-carrier-nodes.mjs | 50 +++- 68 files changed, 3985 insertions(+), 267 deletions(-) delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java delete mode 100644 firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index c620909f7..b94804393 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -8,6 +8,14 @@ baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} lastleg: event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/cn/integration-service.yml index 5c3f3ed6c..d4dd01f9c 100644 --- a/config/nacos/cn/integration-service.yml +++ b/config/nacos/cn/integration-service.yml @@ -21,3 +21,39 @@ baiyue: max-qps-retries: ${BAIDU_OCR_MAX_QPS_RETRIES:3} amazon-sp-api: enabled: ${AMAZON_SP_API_ENABLED:false} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 1676cb3a7..c94b2aa41 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -6,6 +6,12 @@ spring.datasource: baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} baiyue.clients.order.url: ${ORDER_SERVICE_URL:} baiyue.clients.file.url: ${FILE_SERVICE_URL:} +baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} +baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} +baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} firstleg: inbound: allow-rejected-measurement: false diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml index 9ed48b24c..b02d9d323 100644 --- a/config/nacos/local/integration-service.yml +++ b/config/nacos/local/integration-service.yml @@ -15,3 +15,39 @@ baiyue: max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} amazon-sp-api: enabled: ${AMAZON_SP_API_ENABLED:false} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3d613af29..93d9a5eb0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,12 @@ services: FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} + MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} + SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} + ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} + COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} finance: <<: *common @@ -187,6 +193,36 @@ services: AMAZON_SP_API_AWS_ACCESS_KEY: ${AMAZON_SP_API_AWS_ACCESS_KEY:-} AMAZON_SP_API_AWS_SECRET_KEY: ${AMAZON_SP_API_AWS_SECRET_KEY:-} AMAZON_SP_API_REGION: ${AMAZON_SP_API_REGION:-} + MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} + MATSON_TRACKING_PAGE_URL: ${MATSON_TRACKING_PAGE_URL:-https://www.matson.com/shipment-tracking.html} + MATSON_TRACKING_QUERY_URL: ${MATSON_TRACKING_QUERY_URL:-https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + MATSON_TRACKING_CONNECT_TIMEOUT: ${MATSON_TRACKING_CONNECT_TIMEOUT:-10s} + MATSON_TRACKING_REQUEST_TIMEOUT: ${MATSON_TRACKING_REQUEST_TIMEOUT:-30s} + MATSON_TRACKING_MIN_REQUEST_INTERVAL: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:-5s} + SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} + SML_TRACKING_QUERY_URL: ${SML_TRACKING_QUERY_URL:-https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + SML_TRACKING_CONNECT_TIMEOUT: ${SML_TRACKING_CONNECT_TIMEOUT:-10s} + SML_TRACKING_REQUEST_TIMEOUT: ${SML_TRACKING_REQUEST_TIMEOUT:-30s} + SML_TRACKING_MIN_REQUEST_INTERVAL: ${SML_TRACKING_MIN_REQUEST_INTERVAL:-5s} + ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} + ZIM_AUTHORIZATION_URL: ${ZIM_AUTHORIZATION_URL:-https://apigw.zim.com/authorize/v1} + ZIM_TRACING_BASE_URL: ${ZIM_TRACING_BASE_URL:-https://apigw.zim.com/tracing/v2} + ZIM_CLIENT_ID: ${ZIM_CLIENT_ID:-} + ZIM_CLIENT_SECRET: ${ZIM_CLIENT_SECRET:-} + ZIM_TRACKING_SUBSCRIPTION_KEY: ${ZIM_TRACKING_SUBSCRIPTION_KEY:-} + ZIM_TRACKING_CONNECT_TIMEOUT: ${ZIM_TRACKING_CONNECT_TIMEOUT:-10s} + ZIM_TRACKING_REQUEST_TIMEOUT: ${ZIM_TRACKING_REQUEST_TIMEOUT:-30s} + ZIM_TRACKING_MAX_CONCURRENCY: ${ZIM_TRACKING_MAX_CONCURRENCY:-10} + EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} + EMC_TRACKING_QUERY_URL: ${EMC_TRACKING_QUERY_URL:-https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + EMC_TRACKING_CONNECT_TIMEOUT: ${EMC_TRACKING_CONNECT_TIMEOUT:-10s} + EMC_TRACKING_REQUEST_TIMEOUT: ${EMC_TRACKING_REQUEST_TIMEOUT:-30s} + EMC_TRACKING_MIN_REQUEST_INTERVAL: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:-5s} + HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + HEDE_TRACKING_QUERY_URL: ${HEDE_TRACKING_QUERY_URL:-http://elines.hedehk.com/getVBilldynamic} + HEDE_TRACKING_CONNECT_TIMEOUT: ${HEDE_TRACKING_CONNECT_TIMEOUT:-10s} + HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s} + HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s} event-bridge-service: <<: *common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 981c17abc..7d4038556 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10244,11 +10244,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', - latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', - latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', stopped_at DATETIME(3) NULL COMMENT '任务停止时间', stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', @@ -10290,12 +10290,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN - ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -10306,7 +10307,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -10320,13 +10321,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -10339,7 +10340,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_history_action CHECK - (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( @@ -10347,6 +10349,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', @@ -10363,10 +10366,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK - (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), - CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) ) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -10409,6 +10437,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -10422,6 +10452,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; @@ -10485,7 +10519,7 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', - 'fl_carrier_schedule_node_fact')) <> 5 THEN + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission @@ -11899,6 +11933,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', '船节点异常发生与解除不可变事实'); CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划'); CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联'); @@ -12479,7 +12514,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_at' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by', '最后修改人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前查询窗口已执行次数;最多3次'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); @@ -12503,10 +12538,10 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务操作状态:READY、MANUAL_REQUIRED、STOPPED'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近实际标准船节点;预计事实不推进'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近实际标准节点发生时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); @@ -12520,7 +12555,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updat CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前三天查询窗口起始时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap', '是否存在自动化覆盖缺口:0否、1是'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'coverage_gap_code', '自动化覆盖缺口编码'); @@ -12534,7 +12569,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前三天窗口内的尝试序号;取值1至3'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'created_at', '历史记录创建时间'); @@ -12559,6 +12594,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'i CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号'); @@ -12570,6 +12606,20 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'l CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'id', '船节点异常事实ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'query_id', '所属查询任务ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'event_key', '供应商异常事件幂等键'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_code', '供应商编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_event_id', '供应商稳定事件ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'provider_status', '供应商原始异常状态'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'exception_code', '平台异常编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'active', '1异常发生;0异常解除'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'observed_at', '供应商观测时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_code', '地点编码'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'location_name', '地点名称'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'source_reference', '可审计来源引用'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'raw_payload_hash', '原始响应SHA-256'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_exception_fact', 'created_at', '事实入库时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致'); CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 9a8cc9486..ea318b6a5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -31,7 +31,7 @@ | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | -| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` | +| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | @@ -51,7 +51,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java index 89606b826..7e3d34a12 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java @@ -20,9 +20,13 @@ public interface CarrierScheduleIntegrationClient { } record QueryRequest(String carrierCode, String trackingType, String trackingNo) { } record QueryResult(String providerCode, String outcome, List observations, + List exceptions, String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } record Observation(String providerEventId, String providerNodeCode, String standardNode, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { } + record ExceptionObservation(String providerEventId, String providerStatus, String exceptionCode, + boolean active, LocalDateTime observedAt, String locationCode, + String locationName, String sourceReference, String rawPayloadHash) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java deleted file mode 100644 index 3187f3912..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/InternalCarrierScheduleQueryController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.baiyue.firstleg.portschedule.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; -import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.Size; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.PathVariable; -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.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -@RestController -@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries") -public class InternalCarrierScheduleQueryController { - private final CarrierScheduleQueryService service; - @Value("${baiyue.security.internal-service-token}") private String expectedToken; - - public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; } - - @PostMapping("/{queryId}/stop") - public ApiResponse stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, - @PathVariable @Positive long queryId, - @Valid @RequestBody StopRequest request) { - authenticate(token); - return ApiResponse.success(service.stop(queryId, request.reason())); - } - - private void authenticate(String token) { - if (token == null || expectedToken == null || expectedToken.isBlank() - || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), - expectedToken.getBytes(StandardCharsets.UTF_8))) { - throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); - } - } - - public record StopRequest(@NotBlank @Size(max = 500) String reason) { } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java index 90a5ce73a..4153fa1e2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryController.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; @@ -93,4 +94,16 @@ public class PlatformCarrierScheduleQueryController { FirstlegScopeGuard.platform(user, service.customerId(queryId)); return ApiResponse.success(service.attachManualResult(queryId, user.userId(), user.username(), key, request)); } + + @PostMapping("/{queryId}/manual-nodes") + @PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')") + @BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY", + resourceId = "#queryId", description = "人工补录船节点事实") + public ApiResponse manualNode(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long queryId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ManualNodeRequest request) { + FirstlegScopeGuard.platform(user, service.customerId(queryId)); + return ApiResponse.success(service.addManualNode(queryId, user.userId(), user.username(), key, request)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..e24dfb348 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.portschedule.domain; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static CarrierScheduleExceptionCode require(String value) { + try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); } + catch (IllegalArgumentException exception) { + throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点异常:" + value); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java index 724fc5524..5ace71e11 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/domain/CarrierScheduleNode.java @@ -9,10 +9,14 @@ import java.util.Locale; public enum CarrierScheduleNode { DEPARTED_WAREHOUSE(10), AT_ORIGIN_PORT(20), - DEPARTED_ORIGIN_PORT(30), - IN_TRANSIT(40), - ARRIVED(50), - PICKUP_READY(60); + 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); private final int sequence; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java index b0fe8b792..4c5a90237 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtos.java @@ -16,9 +16,9 @@ public final class CarrierScheduleQueryDtos { public record Query(@Positive Long customerId, @Positive Long oceanBookingId, @Positive Long containerBookingId, @Size(max = 16) String carrierCode, @Size(max = 24) String status, Boolean expired, - @Size(max = 128) String keyword, @Min(1) long page, - @Min(1) @Max(200) long size) { - public Query { if (page == 0) page = 1; if (size == 0) size = 20; } + @Size(max = 128) String keyword, @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public Query { if (page == null || page == 0) page = 1L; if (size == null || size == 0) size = 20L; } } public record CreateRequest(@NotNull @Min(0) @Max(0) Integer expectedVersion, @@ -34,6 +34,15 @@ public final class CarrierScheduleQueryDtos { @NotNull @Positive Long portScheduleId, @NotBlank @Size(max = 1000) String reason) {} + public record ManualNodeRequest(@NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 32) String standardNode, + @NotNull LocalDateTime observedAt, + LocalDateTime estimatedAt, + LocalDateTime actualAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @NotBlank @Size(max = 1000) String reason) {} + public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode, @NotBlank @Size(max = 128) String providerEventId, @NotBlank @Size(max = 64) String providerNodeCode, @@ -46,6 +55,17 @@ public final class CarrierScheduleQueryDtos { @Size(max = 500) String sourceReference, @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record ProviderExceptionRequest(@NotBlank @Size(max = 32) String providerCode, + @NotBlank @Size(max = 128) String providerEventId, + @NotBlank @Size(max = 128) String providerStatus, + @NotBlank @Size(max = 32) String exceptionCode, + boolean active, + @NotNull LocalDateTime observedAt, + @Size(max = 32) String locationCode, + @Size(max = 128) String locationName, + @Size(max = 500) String sourceReference, + @NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {} + public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl, String accessMode, boolean automated, boolean coverageGap, String coverageGapCode, String manualReason) {} @@ -53,21 +73,28 @@ public final class CarrierScheduleQueryDtos { public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId, Long containerBookingId, String carrierCode, String trackingType, String trackingNo, String officialUrl, String accessMode, String status, String triggerSource, - String latestStandardNode, LocalDateTime latestNodeObservedAt, + String latestActualNode, LocalDateTime latestActualAt, LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt, Long resultPortScheduleId, String manualReason, String lastErrorCode, String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {} public record QueryView(Task query, boolean expired) {} - public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode, + public record NodeFact(Long id, String providerCode, String providerEventId, + String providerNodeCode, String standardNode, int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + public record ExceptionFact(Long id, String providerCode, String providerEventId, + String providerStatus, String exceptionCode, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {} + public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason, Long operatorId, String operatorName, LocalDateTime createdAt) {} /** Frontend detail intentionally excludes scheduler leases and raw query attempts. */ - public record Detail(QueryView query, List nodes, List histories) {} + public record Detail(QueryView query, List nodes, List exceptions, + List histories) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java new file mode 100644 index 000000000..e7fa6c220 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java @@ -0,0 +1,31 @@ +package com.baiyue.firstleg.portschedule.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_carrier_schedule_exception_fact") +public class CarrierScheduleExceptionFactEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long queryId; + private String eventKey; + private String providerCode; + private String providerEventId; + private String providerStatus; + private String exceptionCode; + private Boolean active; + private LocalDateTime observedAt; + private String locationCode; + private String locationName; + private String sourceReference; + private String rawPayloadHash; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java index 3015f55c3..8df45bac0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleNodeFactEntity.java @@ -18,6 +18,7 @@ public class CarrierScheduleNodeFactEntity { private Long queryId; private String eventKey; private String providerCode; + private String providerEventId; private String providerNodeCode; private String standardNode; private Integer nodeSequence; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java new file mode 100644 index 000000000..6032850bb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleExceptionFactMapper.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.portschedule.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface CarrierScheduleExceptionFactMapper extends BaseMapper { + @Insert(""" + INSERT INTO fl_carrier_schedule_exception_fact + (id,query_id,event_key,provider_code,provider_event_id,provider_status,exception_code,active, + observed_at,location_code,location_name,source_reference,raw_payload_hash,created_at) + VALUES + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId}, + #{value.providerStatus},#{value.exceptionCode},#{value.active},#{value.observedAt}, + #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, + CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE id=id + """) + int insertIfAbsent(@Param("value") CarrierScheduleExceptionFactEntity value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java index a5aac0ec7..f20d62918 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleNodeFactMapper.java @@ -10,10 +10,10 @@ import org.apache.ibatis.annotations.Param; public interface CarrierScheduleNodeFactMapper extends BaseMapper { @Insert(""" INSERT INTO fl_carrier_schedule_node_fact - (id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at, + (id,query_id,event_key,provider_code,provider_event_id,provider_node_code,standard_node,node_sequence,observed_at, estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at) VALUES - (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode}, + (#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerEventId},#{value.providerNodeCode}, #{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt}, #{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash}, CURRENT_TIMESTAMP(3)) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 69b0d47f2..1f3e38eca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -48,7 +48,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper page(Query query) { return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query)); @@ -67,6 +70,15 @@ public class CarrierScheduleQueryRepository { .orderByAsc(CarrierScheduleNodeFactEntity::getId)); } public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; } + public List exceptions(long id) { + return exceptions.selectList(new LambdaQueryWrapper() + .eq(CarrierScheduleExceptionFactEntity::getQueryId, id) + .orderByAsc(CarrierScheduleExceptionFactEntity::getObservedAt) + .orderByAsc(CarrierScheduleExceptionFactEntity::getId)); + } + public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) { + return exceptions.insertIfAbsent(value) == 1; + } 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/CarrierScheduleAdapterRegistry.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java index 82e780b8e..50a5919ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistry.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -16,15 +17,27 @@ import java.util.Map; @Component public class CarrierScheduleAdapterRegistry { private static final Map DEFINITIONS = definitions(); + @Value("${baiyue.carrier-schedule.providers.matson-automated:false}") + private boolean matsonAutomated; + @Value("${baiyue.carrier-schedule.providers.hede-automated:false}") + private boolean hedeAutomated; + @Value("${baiyue.carrier-schedule.providers.sml-automated:false}") + private boolean smlAutomated; + @Value("${baiyue.carrier-schedule.providers.zim-official-api-enabled:false}") + private boolean zimOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.cosco-official-api-enabled:false}") + private boolean coscoOfficialApiEnabled; + @Value("${baiyue.carrier-schedule.providers.emc-automated:false}") + private boolean emcAutomated; public List descriptors() { - return DEFINITIONS.values().stream().map(Definition::descriptor).toList(); + return DEFINITIONS.values().stream().map(value -> value.descriptor(automated(value))).toList(); } public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { Definition definition = required(carrierCode); return new PreparedAttempt(definition.code(), definition.accessMode(), - officialUrl(definition, trackingType, trackingNo), "MANUAL_REQUIRED", + officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED", definition.coverageGapCode(), definition.manualReason()); } @@ -52,9 +65,6 @@ public class CarrierScheduleAdapterRegistry { private static String officialUrl(Definition definition, String trackingType, String trackingNo) { String encoded = URLEncoder.encode(trackingNo.trim(), StandardCharsets.UTF_8); - if ("ZIM".equals(definition.code())) { - return definition.officialUrl() + "?consnumber=" + encoded; - } if ("EMC".equals(definition.code())) { String type = "CONTAINER_NO".equalsIgnoreCase(trackingType) ? "cntr" : "bl"; return definition.officialUrl() + "?c=" + encoded + "&t=" + type + "&url=EVERGREEN"; @@ -65,26 +75,40 @@ public class CarrierScheduleAdapterRegistry { private static Map definitions() { Map values = new LinkedHashMap<>(); add(values, new Definition("MATSON", "美森", "https://www.matson.com/shipment-tracking.html", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开交互表单,当前需人工查询并录入结果")); + "PUBLIC_WEB_PROXY", null, null)); add(values, new Definition("SML", "SML", "https://esvc.smlines.com/smline/CUP_HOM_3301.do?sessLocale=zh", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); - add(values, new Definition("ZIM", "ZIM", "https://www.zimchina.com/tools/track-a-shipment", - "HCAPTCHA", "HCAPTCHA_REQUIRED", "官网启用hCaptcha,禁止绕过验证码,必须人工查询")); - add(values, new Definition("COSCO", "COSCO", "https://elines.coscoshipping.com/ebusiness/", - "DYNAMIC_IFRAME", "DYNAMIC_IFRAME_REQUIRED", "官网使用动态页面/iframe,未发现稳定公开API,必须人工查询")); + "PUBLIC_FORM", null, null)); + add(values, new Definition("ZIM", "ZIM", "https://api.zim.com/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "ZIM Cargo Tracking API 待配置订阅凭据及来源授权,启用前必须人工查询")); + add(values, new Definition("COSCO", "COSCO", "https://cop.lines.coscoshipping.com/copPortal/", + "OFFICIAL_API", "OFFICIAL_API_CREDENTIALS_REQUIRED", + "COSCO Shipping Open Platform Cargo Tracking API 待注册订阅及凭据,启用前必须人工查询")); add(values, new Definition("EMC", "EMC", "https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do", "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网仅公开查询表单,当前需人工确认页面结果后录入")); add(values, new Definition("HEDE", "合德", "http://elines.hedehk.com/cargoDynamic", - "PUBLIC_FORM", "NO_DOCUMENTED_STRUCTURED_API", "官网未提供稳定公开结构化接口,当前需人工查询并录入结果")); + "PUBLIC_HTML", null, null)); return Collections.unmodifiableMap(values); } private static void add(Map values, Definition value) { values.put(value.code(), value); } + private boolean automated(Definition definition) { + return switch (definition.code()) { + case "MATSON" -> matsonAutomated; + case "SML" -> smlAutomated; + case "ZIM" -> zimOfficialApiEnabled; + case "COSCO" -> coscoOfficialApiEnabled; + case "EMC" -> emcAutomated; + case "HEDE" -> hedeAutomated; + default -> false; + }; + } + private record Definition(String code, String name, String officialUrl, String accessMode, String coverageGapCode, String manualReason) { - CarrierDescriptor descriptor() { - return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true, + CarrierDescriptor descriptor(boolean automated) { + return new CarrierDescriptor(code, name, officialUrl, accessMode, automated, !automated, coverageGapCode, manualReason); } boolean matches(String value) { 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 d1e28ef83..28c72c2a1 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 @@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.service; import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import org.springframework.beans.factory.annotation.Value; @@ -61,6 +62,14 @@ public class CarrierScheduleQueryScheduler { 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()); } catch (RuntimeException exception) { 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 adaa9a117..69315989c 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 @@ -9,17 +9,23 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDesc import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ExceptionFact; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task; import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleExceptionCode; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; @@ -128,7 +134,6 @@ public class CarrierScheduleQueryService { @Transactional public Detail applyProviderObservation(long id, ProviderObservationRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); - if ("STOPPED".equals(value.getStatus())) return detail(value); CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node, @@ -136,40 +141,83 @@ public class CarrierScheduleQueryService { request.locationName(), request.sourceReference(), request.rawPayloadHash())) { return detail(value); } - CarrierScheduleNode current = value.getLatestStandardNode() == null ? null - : CarrierScheduleNode.require(value.getLatestStandardNode()); - if (current == null || node.sequence() >= current.sequence()) { - value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt()); - value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt()); - value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); - value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED" - : node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY"); - value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); - if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); - } - CarrierScheduleQueryEntity after = required(id); + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + 0L, "carrier-scheduler"); + CarrierScheduleQueryEntity after = advanced ? required(id) : value; addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before, after, 0L, "carrier-scheduler"); return detail(after); } @Transactional - public Detail stop(long id, String reason) { + public Detail applyProviderException(long id, ProviderExceptionRequest request) { CarrierScheduleQueryEntity value = requiredLocked(id); - if ("STOPPED".equals(value.getStatus())) return detail(value); + CarrierScheduleExceptionCode code = CarrierScheduleExceptionCode.require(request.exceptionCode()); + if (!addException(id, request.providerCode(), request.providerEventId(), request.providerStatus(), code, + request.active(), request.observedAt(), request.locationCode(), request.locationName(), + request.sourceReference(), request.rawPayloadHash())) { + return detail(value); + } + addHistory(id, "EXCEPTION_OBSERVED", value.getVersion(), value.getVersion(), code.name(), value, value, + 0L, "carrier-scheduler"); + return detail(value); + } + + @Transactional + public Detail addManualNode(long id, long operatorId, String operatorName, String key, + ManualNodeRequest request) { + String operation = "MANUAL_NODE"; + Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request)); + if (replay != null) return replay; + if (request.estimatedAt() == null && request.actualAt() == null) { + throw badRequest("estimatedAt和actualAt至少提供一个"); + } + CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion()); + CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode()); CarrierScheduleQueryEntity before = copy(value); - value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason)); - value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); - if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); - CarrierScheduleQueryEntity after = required(id); - addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L, - "carrier-scheduler"); - return detail(after); + String providerEventId = sha256("MANUAL|" + id + '|' + key); + if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(), + request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(), + "MANUAL:" + operatorId, hash(operation, id, request))) { + return complete(id, operatorId, key, detail(value)); + } + boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(), + operatorId, operatorName); + CarrierScheduleQueryEntity after = advanced ? required(id) : value; + addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after, + operatorId, operatorName); + return complete(id, operatorId, key, detail(after)); + } + + private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node, + LocalDateTime actualAt, LocalDateTime observedAt, + long operatorId, String operatorName) { + if (actualAt == null || "STOPPED".equals(value.getStatus())) return false; + CarrierScheduleNode current = value.getLatestStandardNode() == null ? null + : CarrierScheduleNode.require(value.getLatestStandardNode()); + boolean advances = current == null || node.sequence() > current.sequence() + || node.sequence() == current.sequence() && (value.getLatestNodeObservedAt() == null + || actualAt.isAfter(value.getLatestNodeObservedAt())); + if (advances) { + value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(actualAt); + value.setLastSuccessAt(now()); value.setSourceUpdatedAt(observedAt); + value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null); + if (node == CarrierScheduleNode.EMPTY_RETURNED) { + value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜"); + value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); + } else { + value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + } + if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); + return true; + } + return false; } @Transactional public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryEntity value = requiredLocked(id); + insertAttempt(value, outcome, errorCode, errorMessage); if ("STOPPED".equals(value.getStatus())) return; CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); @@ -177,6 +225,7 @@ public class CarrierScheduleQueryService { value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { + value.setStatus("READY"); value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); } else { @@ -189,9 +238,7 @@ public class CarrierScheduleQueryService { value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); value.setConsecutiveFailures(failures); if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { - if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) { - value.setStatus("MANUAL_REQUIRED"); - } + value.setStatus("MANUAL_REQUIRED"); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); } else { long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); @@ -208,6 +255,20 @@ public class CarrierScheduleQueryService { "carrier-scheduler"); } + private void insertAttempt(CarrierScheduleQueryEntity value, String outcome, + String errorCode, String errorMessage) { + CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); + attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); + attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1))); + attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); + attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); + boolean manual = "MANUAL_REQUIRED".equals(outcome); + attempt.setCoverageGap(manual); attempt.setCoverageGapCode(manual ? trim(errorCode) : null); + attempt.setManualReason(manual ? trim(errorMessage) : null); attempt.setErrorCode(trim(errorCode)); + attempt.setErrorMessage(trim(errorMessage)); attempt.setOperatorId(0L); + attempt.setOperatorName("carrier-scheduler"); repository.insert(attempt); + } + @Transactional public Detail create(long operatorId, String operatorName, String key, CreateRequest request) { String operation = "CREATE"; @@ -309,16 +370,24 @@ public class CarrierScheduleQueryService { private Detail detail(CarrierScheduleQueryEntity value) { return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(), + repository.exceptions(value.getId()).stream().map(this::exception).toList(), repository.histories(value.getId()).stream().map(this::history).toList()); } private NodeFact node(CarrierScheduleNodeFactEntity value) { - return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(), + return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), value.getProviderNodeCode(), value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(), value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), value.getRawPayloadHash(), value.getCreatedAt()); } + private ExceptionFact exception(CarrierScheduleExceptionFactEntity value) { + return new ExceptionFact(value.getId(), value.getProviderCode(), value.getProviderEventId(), + value.getProviderStatus(), value.getExceptionCode(), Boolean.TRUE.equals(value.getActive()), + value.getObservedAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(), + value.getRawPayloadHash(), value.getCreatedAt()); + } + private History history(CarrierScheduleQueryHistoryEntity value) { return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(), value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt()); @@ -332,6 +401,7 @@ public class CarrierScheduleQueryService { fact.setId(IdWorker.getId()); fact.setQueryId(queryId); fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name()); fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt); fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName)); @@ -339,6 +409,21 @@ public class CarrierScheduleQueryService { return repository.insertIfAbsent(fact); } + private boolean addException(long queryId, String providerCode, String providerEventId, + String providerStatus, CarrierScheduleExceptionCode code, boolean active, + LocalDateTime observedAt, String locationCode, String locationName, + String sourceReference, String rawPayloadHash) { + CarrierScheduleExceptionFactEntity fact = new CarrierScheduleExceptionFactEntity(); + fact.setId(IdWorker.getId()); fact.setQueryId(queryId); + fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim()); + fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT)); + fact.setProviderEventId(providerEventId.trim()); fact.setProviderStatus(providerStatus.trim()); + 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); + } + private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) { boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null || !value.getExpiresAt().isAfter(now); diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 81cab7574..e3aa7af0b 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -25,6 +25,14 @@ spring: max-file-size: 5MB max-request-size: 6MB baiyue: + carrier-schedule: + providers: + matson-automated: ${MATSON_TRACKING_ENABLED:false} + sml-automated: ${SML_TRACKING_ENABLED:false} + zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} + cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} + emc-automated: ${EMC_TRACKING_ENABLED:false} + hede-automated: ${HEDE_TRACKING_ENABLED:false} security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 5b58c4d67..c9062effe 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -12,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照', - access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', - status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED', + access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API', + status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', - latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点', - latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间', + latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', + latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间', stopped_at DATETIME(3) NULL COMMENT '任务停止时间', stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', @@ -58,12 +58,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_tracking CHECK (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), CONSTRAINT chk_carrier_schedule_query_access CHECK - (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + (access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')), CONSTRAINT chk_carrier_schedule_query_status CHECK - (status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')), + (status IN ('READY','MANUAL_REQUIRED','STOPPED')), CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN - ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), + ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) ) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; @@ -74,7 +75,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', - attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + attempt_status VARCHAR(24) NOT NULL COMMENT '供应商调用结果', coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是', coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码', manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因', @@ -88,13 +89,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK - (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) + (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + action VARCHAR(24) NOT NULL COMMENT '查询任务业务历史动作', from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空', to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本', reason VARCHAR(1000) NULL COMMENT '操作原因或说明', @@ -107,7 +108,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( KEY idx_carrier_schedule_history_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_history_action CHECK - (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP')) + (action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED', + 'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP')) ) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( @@ -115,6 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码', standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点', node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号', @@ -131,10 +134,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK - (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')), - CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60)) + (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60,70,80,90,100)) ) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实'; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '船节点异常事实ID', + query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + provider_code VARCHAR(32) NOT NULL COMMENT '供应商编码', + provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', + provider_status VARCHAR(128) NOT NULL COMMENT '供应商原始异常状态', + exception_code VARCHAR(32) NOT NULL COMMENT '平台异常编码', + active TINYINT(1) NOT NULL COMMENT '1异常发生;0异常解除', + observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间', + location_code VARCHAR(32) NULL COMMENT '地点编码', + location_name VARCHAR(128) NULL COMMENT '地点名称', + source_reference VARCHAR(500) NULL COMMENT '可审计来源引用', + raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_exception_event (query_id,event_key), + KEY idx_carrier_schedule_exception_timeline (query_id,observed_at,id), + CONSTRAINT fk_carrier_schedule_exception_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_exception_code CHECK + (exception_code IN ('CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD')), + CONSTRAINT chk_carrier_schedule_exception_active CHECK (active IN (0,1)) +) ENGINE=InnoDB COMMENT='船节点异常发生与解除不可变事实'; + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID', query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', @@ -177,6 +205,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update; DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_exception_before_delete; DELIMITER $$ CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ @@ -190,6 +220,10 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$ CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_update BEFORE UPDATE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_exception_before_delete BEFORE DELETE ON fl_carrier_schedule_exception_fact +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船节点异常事实不可删除'; END$$ DELIMITER ; USE baiyue_iam; @@ -253,7 +287,7 @@ BEGIN WHERE table_schema='baiyue_firstleg' AND table_name IN ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', - 'fl_carrier_schedule_node_fact')) <> 5 THEN + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml deleted file mode 100644 index 30ecf0cd5..000000000 --- a/firstleg/src/main/resources/openapi/carrier-schedule-internal-v1.yaml +++ /dev/null @@ -1,23 +0,0 @@ -openapi: 3.0.3 -info: - title: Carrier Schedule Internal Handoff API - version: 1.0.0 - description: Internal-only handoff contract; it is not a platform or merchant frontend API. -paths: - /internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop: - post: - summary: Stop tracking after the overseas pickup handoff has completed - parameters: - - {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} - - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [reason] - properties: - reason: {type: string, minLength: 1, maxLength: 500} - responses: - '200': {description: Idempotent STOPPED task detail} diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 46a1b3371..1788868a2 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Carrier Schedule Query API version: 1.0.0 - description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness. + description: Phase-4 carrier tracking from domestic outbound confirmation through actual empty return. paths: /api/platform/v1/carrier-schedule-queries/carriers: get: @@ -43,18 +43,21 @@ paths: summary: Get task, standardized node facts and immutable business history x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: Stable task detail} + '200': + description: Stable task detail + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} /api/platform/v1/carrier-schedule-queries/{queryId}/history: parameters: [{$ref: '#/components/parameters/QueryId'}] get: summary: Get immutable task history x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: CREATE, ATTEMPT and MANUAL_RESULT history} + '200': {description: Immutable business history; scheduler attempts are not exposed} /api/platform/v1/carrier-schedule-queries/{queryId}/manual-results: parameters: [{$ref: '#/components/parameters/QueryId'}] post: summary: Link a validated manual port-schedule observation without pretending automated success + description: This legacy port-schedule association does not append a standard node and never stops tracking; phase-4 manual facts use /manual-nodes. x-permission: platform:firstleg:port-schedule-query:manual parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] requestBody: @@ -63,7 +66,24 @@ paths: application/json: schema: {$ref: '#/components/schemas/ManualResultRequest'} responses: - '200': {description: Successful manual fallback and next query time} + '200': + description: Successful manual fallback and next query time + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} + /api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes: + parameters: [{$ref: '#/components/parameters/QueryId'}] + post: + summary: Append one immutable manual estimated or actual standard-node fact + x-permission: platform:firstleg:port-schedule-query:manual + parameters: [{$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ManualNodeRequest'} + responses: + '200': + description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops + content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}} components: parameters: QueryId: {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} @@ -71,8 +91,11 @@ components: schemas: CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]} - QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]} - StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]} + QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} + StandardNode: + type: string + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} CreateRequest: type: object required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo] @@ -92,9 +115,112 @@ components: expectedVersion: {type: integer, minimum: 0} portScheduleId: {type: integer, format: int64, minimum: 1} reason: {type: string, minLength: 1, maxLength: 1000} + ManualNodeRequest: + type: object + required: [expectedVersion, standardNode, observedAt, reason] + description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node. + properties: + expectedVersion: {type: integer, minimum: 0} + standardNode: {$ref: '#/components/schemas/StandardNode'} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + reason: {type: string, minLength: 1, maxLength: 1000} + Task: + type: object + required: [id, queryNo, customerId, oceanBookingId, carrierCode, trackingType, trackingNo, status, triggerSource, version] + properties: + id: {type: integer, format: int64} + queryNo: {type: string} + customerId: {type: integer, format: int64} + oceanBookingId: {type: integer, format: int64} + containerBookingId: {type: integer, format: int64, nullable: true} + carrierCode: {$ref: '#/components/schemas/CarrierCode'} + trackingType: {$ref: '#/components/schemas/TrackingType'} + trackingNo: {type: string} + officialUrl: {type: string, nullable: true} + accessMode: {type: string, nullable: true} + status: {$ref: '#/components/schemas/QueryStatus'} + triggerSource: {type: string} + latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} + latestActualAt: {type: string, format: date-time, nullable: true} + lastSuccessAt: {type: string, format: date-time, nullable: true} + sourceUpdatedAt: {type: string, format: date-time, nullable: true} + expiresAt: {type: string, format: date-time, nullable: true} + resultPortScheduleId: {type: integer, format: int64, nullable: true} + manualReason: {type: string, nullable: true} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true} + stoppedAt: {type: string, format: date-time, nullable: true} + stopReason: {type: string, nullable: true} + version: {type: integer, minimum: 0} QueryView: type: object required: [query, expired] properties: - query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed} + query: {$ref: '#/components/schemas/Task'} expired: {type: boolean} + NodeFact: + type: object + required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerNodeCode: {type: string, maxLength: 64} + standardNode: {$ref: '#/components/schemas/StandardNode'} + nodeSequence: {type: integer, enum: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]} + observedAt: {type: string, format: date-time} + estimatedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + ExceptionFact: + type: object + required: [id, providerCode, providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash, createdAt] + properties: + id: {type: integer, format: int64} + providerCode: {type: string, maxLength: 32} + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean, description: true means hold occurred; false means released} + observedAt: {type: string, format: date-time} + locationCode: {type: string, maxLength: 32, nullable: true} + locationName: {type: string, maxLength: 128, nullable: true} + sourceReference: {type: string, maxLength: 500, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + createdAt: {type: string, format: date-time} + History: + type: object + required: [id, action, createdAt] + properties: + id: {type: integer, format: int64} + action: {type: string, enum: [CREATE, AUTO_CREATE, MANUAL_RESULT, MANUAL_NODE, NODE_OBSERVED, EXCEPTION_OBSERVED, PROVIDER_QUERY]} + fromVersion: {type: integer, nullable: true} + toVersion: {type: integer, nullable: true} + reason: {type: string, nullable: true} + operatorId: {type: integer, format: int64, nullable: true} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + Detail: + type: object + required: [query, nodes, exceptions, histories] + description: Scheduler lease, attempt and internal execution fields are intentionally absent. + properties: + query: {$ref: '#/components/schemas/QueryView'} + nodes: {type: array, items: {$ref: '#/components/schemas/NodeFact'}} + exceptions: {type: array, items: {$ref: '#/components/schemas/ExceptionFact'}} + histories: {type: array, items: {$ref: '#/components/schemas/History'}} + DetailEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/Detail'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java index d8546b86e..563955f74 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/CarrierScheduleQueryOpenApiContractTest.java @@ -15,10 +15,17 @@ class CarrierScheduleQueryOpenApiContractTest { assertThat(yaml) .contains("/api/platform/v1/carrier-schedule-queries:") .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:") + .contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:") + .contains("x-permission: platform:firstleg:port-schedule-query:manual") .contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]") - .contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY") - .contains("MANUAL_REQUIRED") - .contains("scheduler leases and raw attempts are never exposed") + .contains("LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED") + .contains("CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD") + .contains("enum: [READY, MANUAL_REQUIRED, STOPPED]") + .contains("latestActualNode:") + .contains("latestActualAt:") + .contains("internal execution fields are intentionally absent") + .doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED") .doesNotContain("/{queryId}/attempts:") .doesNotContain("port-schedule-query:attempt"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java index 1fb6f87e7..b113eaaac 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/controller/PlatformCarrierScheduleQueryControllerContractTest.java @@ -22,13 +22,14 @@ class PlatformCarrierScheduleQueryControllerContractTest { "detail", "platform:firstleg:port-schedule-query:list", "history", "platform:firstleg:port-schedule-query:list", "create", "platform:firstleg:port-schedule-query:create", - "manualResult", "platform:firstleg:port-schedule-query:manual"); + "manualResult", "platform:firstleg:port-schedule-query:manual", + "manualNode", "platform:firstleg:port-schedule-query:manual"); permissions.forEach((name, permission) -> { Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow(); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (name.equals("create") || name.equals("manualResult")) { + if (name.equals("create") || name.equals("manualResult") || name.equals("manualNode")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java new file mode 100644 index 000000000..df038e684 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/dto/CarrierScheduleQueryDtosTest.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.portschedule.dto; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQueryDtosTest { + @Test + void omittedPageAndSizeBindToDocumentedDefaults() { + var query = new CarrierScheduleQueryDtos.Query(null, null, null, null, null, null, null, null, null); + assertThat(query.page()).isEqualTo(1L); + assertThat(query.size()).isEqualTo(20L); + } +} 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 ddaae29ea..ffb9c84fd 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 @@ -18,15 +18,25 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") + .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") .contains("attempt_count BETWEEN 0 AND 3") .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") .contains("标准船节点事实不可修改") - .contains("ARRIVED','PICKUP_READY','STOPPED") + .contains("船节点异常事实不可修改") + .contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')") + .contains("LOADED_ON_VESSEL") + .contains("ARRIVED_DESTINATION_PORT") + .contains("DISCHARGED_FROM_VESSEL") + .contains("PICKED_UP','EMPTY_RETURNED") + .contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD") + .contains("provider_event_id") + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("lease_owner") - .contains("consecutive_failures"); + .contains("consecutive_failures") + .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); } @Test @@ -36,7 +46,8 @@ class CarrierScheduleQuerySqlContractTest { .contains("allocation.container_booking_id=NEW.container_booking_id") .contains("id IN (5102184,5102186,5102188)") .contains("platform:firstleg:port-schedule-query:manual") - .contains("'platform:firstleg:port-schedule-query:attempt'") + .contains("permission.permission_code IN ('platform:firstleg:port-schedule-query:view'") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("船司查询任务角色授权未完整创建"); } @@ -50,7 +61,9 @@ class CarrierScheduleQuerySqlContractTest { .contains("uk_carrier_schedule_query_scope") .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") - .contains("船司查询任务角色授权未完整创建"); + .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") + .contains("船司查询任务角色授权未完整创建") + .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); } private static String moduleSql() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java index b7fc97fda..04f896a2b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleAdapterRegistryTest.java @@ -20,16 +20,18 @@ class CarrierScheduleAdapterRegistryTest { } @Test - void returnsStructuredManualRequirementsForCaptchaAndDynamicPage() { + void returnsStructuredManualRequirementsForCaptchaAndPendingOfficialApiCredentials() { var zim = registry.prepare("zim", "BILL_OF_LADING", "GAOU6898053"); var cosco = registry.prepare("COSCO", "CONTAINER_NO", "C123"); assertThat(zim.status()).isEqualTo("MANUAL_REQUIRED"); - assertThat(zim.accessMode()).isEqualTo("HCAPTCHA"); - assertThat(zim.coverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED"); - assertThat(zim.officialUrl()).contains("consnumber=GAOU6898053"); - assertThat(cosco.accessMode()).isEqualTo("DYNAMIC_IFRAME"); - assertThat(cosco.coverageGapCode()).isEqualTo("DYNAMIC_IFRAME_REQUIRED"); + assertThat(zim.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(zim.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(zim.officialUrl()).isEqualTo("https://api.zim.com/"); + assertThat(cosco.status()).isEqualTo("MANUAL_REQUIRED"); + assertThat(cosco.accessMode()).isEqualTo("OFFICIAL_API"); + assertThat(cosco.coverageGapCode()).isEqualTo("OFFICIAL_API_CREDENTIALS_REQUIRED"); + assertThat(cosco.officialUrl()).isEqualTo("https://cop.lines.coscoshipping.com/copPortal/"); } @Test 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 c54b003d3..6b4c92c6e 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 @@ -26,16 +26,20 @@ class CarrierScheduleQuerySchedulerTest { task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567"); LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0); when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true); - var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED", + var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "DISCHARGED_FROM_VESSEL", now, null, now, "USLAX", "Los Angeles", "provider:evt-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + var exception = new CarrierScheduleIntegrationClient.ExceptionObservation("hold-1", "CUSTOMS HOLD", + "CUSTOMS_HOLD", true, now, "USLAX", "Los Angeles", "provider:hold-1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null, - new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), + new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation), List.of(exception), null, null, now))); scheduler.execute(task, now); verify(service).applyProviderObservation(anyLong(), any()); + verify(service).applyProviderException(anyLong(), any()); verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } } 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 2673fe3dc..9930d54c5 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 @@ -5,8 +5,12 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest; 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.PortScheduleEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository; @@ -15,7 +19,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Clock; import java.time.Instant; @@ -28,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,6 +54,7 @@ class CarrierScheduleQueryServiceTest { when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); when(repository.nodes(anyLong())).thenReturn(List.of()); + when(repository.exceptions(anyLong())).thenReturn(List.of()); when(repository.histories(anyLong())).thenReturn(List.of()); } @@ -86,7 +94,7 @@ class CarrierScheduleQueryServiceTest { } @Test - void providerObservationAdvancesToArrivedWithoutExposingAttempts() { + void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { CarrierScheduleQueryEntity task = task(0, 0); task.setLatestStandardNode("IN_TRANSIT"); when(repository.lock(1L)).thenReturn(task); @@ -96,14 +104,172 @@ class CarrierScheduleQueryServiceTest { when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1", - "DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", + "DISCHARGED", "ARRIVED_DESTINATION_PORT", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); - assertThat(result.query().query().status()).isEqualTo("ARRIVED"); - assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED"); + assertThat(result.query().query().status()).isEqualTo("READY"); + assertThat(result.query().query().latestActualNode()).isEqualTo("ARRIVED_DESTINATION_PORT"); assertThat(result.nodes()).isEmpty(); } + @Test + void estimatedObservationIsSavedWithoutAdvancingActualNode() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setLatestStandardNode("LOADED_ON_VESSEL"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "eta-1", "PRE_ARRIVAL", + "ARRIVED_DESTINATION_PORT", NOW, NOW.plusDays(10), null, null, null, "HEDE:X", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + + assertThat(task.getLatestStandardNode()).isEqualTo("LOADED_ON_VESSEL"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void lateActualFactIsSavedButCannotRegressLatestNode() { + CarrierScheduleQueryEntity task = task(0, 5); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "late-1", "DISCHARGED", + "DISCHARGED_FROM_VESSEL", NOW, null, NOW.minusDays(2), null, null, "HEDE:X", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); + + assertThat(task.getLatestStandardNode()).isEqualTo("PICKED_UP"); + verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString()); + } + + @Test + void onlyActualEmptyReturnedStopsTask() { + CarrierScheduleQueryEntity task = task(0, 2); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.applyProviderObservation(1L, new ProviderObservationRequest("HEDE", "return-1", "EMPTY_RETURNED", + "EMPTY_RETURNED", NOW, null, NOW, null, null, "HEDE:X", + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")); + + assertThat(task.getStatus()).isEqualTo("STOPPED"); + assertThat(task.getStopReason()).isEqualTo("已还柜"); + assertThat(task.getNextAllowedAt()).isNull(); + } + + @Test + void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() { + CarrierScheduleQueryEntity task = task(0, 3); + task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class))) + .thenReturn(true); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + var result = service.addManualNode(1L, 7L, "admin", "manual-empty-1", + new ManualNodeRequest(3, "EMPTY_RETURNED", NOW, null, NOW, null, "Los Angeles", "人工确认还柜")); + + assertThat(result.query().query().status()).isEqualTo("STOPPED"); + assertThat(result.query().query().latestActualNode()).isEqualTo("EMPTY_RETURNED"); + verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString()); + } + + @Test + void holdIsPersistedAsSeparateImmutableExceptionFact() { + CarrierScheduleQueryEntity task = task(0, 1); + when(repository.lock(1L)).thenReturn(task); + when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true); + + service.applyProviderException(1L, new ProviderExceptionRequest("MATSON_PUBLIC_TRACKING", "hold-1", + "CONTAINER ON HOLD - Customs Clearance", "CUSTOMS_HOLD", true, NOW, "USLAX", + "Long Beach", "MATSON:9974749", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd")); + + ArgumentCaptor fact = + ArgumentCaptor.forClass(CarrierScheduleExceptionFactEntity.class); + verify(repository).insertIfAbsent(fact.capture()); + assertThat(fact.getValue().getExceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(fact.getValue().getActive()).isTrue(); + assertThat(task.getLatestStandardNode()).isNull(); + } + + @Test + void everySchedulerCompletionWritesImmutableAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + service.completeScheduledAttempt(1L, "NO_CHANGE", null, null); + + ArgumentCaptor attempt = + ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); + verify(repository).insert(attempt.capture()); + assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE"); + assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler"); + assertThat(task.getStatus()).isEqualTo("READY"); + } + + @Test + void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() { + CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry(); + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isFalse(); + assertThat(value.coverageGap()).isTrue(); + }); + + ReflectionTestUtils.setField(registry, "matsonAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "MATSON".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_WEB_PROXY"); + }); + + ReflectionTestUtils.setField(registry, "hedeAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "HEDE".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_HTML"); + }); + + ReflectionTestUtils.setField(registry, "smlAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "SML".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); + + ReflectionTestUtils.setField(registry, "zimOfficialApiEnabled", true); + + assertThat(registry.descriptors()).filteredOn(value -> "ZIM".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("OFFICIAL_API"); + }); + + ReflectionTestUtils.setField(registry, "emcAutomated", true); + + assertThat(registry.descriptors()).filteredOn(value -> "EMC".equals(value.carrierCode())) + .singleElement().satisfies(value -> { + assertThat(value.automated()).isTrue(); + assertThat(value.coverageGap()).isFalse(); + assertThat(value.accessMode()).isEqualTo("PUBLIC_FORM"); + }); + } + @Test void manualResultMustBelongToSameCustomerBookingAndContainer() { CarrierScheduleQueryEntity task = task(1, 1); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 5cd6b548c..5e0c86875 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index b049ac582..232328dc0 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -11,7 +11,7 @@ - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` -- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md` +- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/四期/船节点标准化契约.md` - 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml` - 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test` diff --git a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java index 7ad1b09aa..0a4f88a6c 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java @@ -8,7 +8,8 @@ import java.nio.file.Path; import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.integration") -public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) { +public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi, + CarrierSchedule carrierSchedule) { public record BaiduOcr(boolean enabled, String apiKey, String secretKey, @@ -84,6 +85,136 @@ public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) } } + public record CarrierSchedule(Matson matson, Hede hede, Sml sml, Zim zim, Evergreen evergreen) { + public record Matson(boolean enabled, + URI pageUrl, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { + return enabled && pageUrl != null && queryUrl != null; + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Hede(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Sml(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Zim(boolean enabled, + URI authorizationUrl, + URI tracingBaseUrl, + String clientId, + String clientSecret, + String subscriptionKey, + Duration connectTimeout, + Duration requestTimeout, + int maxConcurrency) { + public boolean configured() { + return enabled && authorizationUrl != null && tracingBaseUrl != null + && hasText(clientId) && hasText(clientSecret) && hasText(subscriptionKey); + } + + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + + public int effectiveMaxConcurrency() { return Math.max(1, Math.min(10, maxConcurrency)); } + + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + + public record Evergreen(boolean enabled, + URI queryUrl, + Duration connectTimeout, + Duration requestTimeout, + Duration minRequestInterval) { + public boolean configured() { return enabled && queryUrl != null; } + public Duration effectiveConnectTimeout() { + return positive(connectTimeout) ? connectTimeout : Duration.ofSeconds(10); + } + public Duration effectiveRequestTimeout() { + return positive(requestTimeout) ? requestTimeout : Duration.ofSeconds(30); + } + public Duration effectiveMinRequestInterval() { + return minRequestInterval != null && !minRequestInterval.isNegative() + ? minRequestInterval : Duration.ofSeconds(5); + } + private static boolean positive(Duration value) { + return value != null && !value.isNegative() && !value.isZero(); + } + } + } + private static boolean hasText(String value) { return value != null && !value.isBlank(); } diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java new file mode 100644 index 000000000..f8e956b08 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java @@ -0,0 +1,15 @@ +package com.baiyue.integration.domain; + +import java.util.Locale; + +public enum CarrierScheduleExceptionCode { + CUSTOMS_HOLD, + TERMINAL_HOLD, + DOCUMENT_HOLD, + UNKNOWN_HOLD; + + public static boolean supports(String value) { + try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } + catch (IllegalArgumentException exception) { return false; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java index 430ec27fc..8edc347d9 100644 --- a/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java +++ b/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java @@ -5,10 +5,14 @@ import java.util.Locale; public enum CarrierScheduleStandardNode { DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, + LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, - ARRIVED, - PICKUP_READY; + ARRIVED_DESTINATION_PORT, + DISCHARGED_FROM_VESSEL, + PICKUP_READY, + PICKED_UP, + EMPTY_RETURNED; public static boolean supports(String value) { try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; } diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java index 4d94ac5ae..7692a0919 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/CarrierScheduleDtos.java @@ -14,10 +14,16 @@ public final class CarrierScheduleDtos { @NotBlank @Size(max = 128) String trackingNo) { } public record QueryResult(String providerCode, String outcome, List observations, + List exceptions, String errorCode, String errorMessage, LocalDateTime providerObservedAt) { } public record Observation(String providerEventId, String providerNodeCode, String standardNode, LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt, String locationCode, String locationName, String sourceReference, String rawPayloadHash) { } + + public record ExceptionObservation(String providerEventId, String providerStatus, + String exceptionCode, boolean active, LocalDateTime observedAt, + String locationCode, String locationName, String sourceReference, + String rawPayloadHash) { } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java index 9a4f219a2..d3f4db74a 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/CompliantCarrierScheduleProviderAdapter.java @@ -4,6 +4,8 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.dto.ProviderStatus; import org.springframework.stereotype.Component; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import java.time.LocalDateTime; import java.util.List; @@ -15,6 +17,7 @@ import java.util.Set; * an explicit provider-unavailable outcome until a documented provider API adapter is configured. */ @Component +@Order(Ordered.LOWEST_PRECEDENCE) public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { private static final Set CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE"); @@ -25,11 +28,11 @@ public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleP @Override public QueryResult query(QueryRequest request) { - return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), + return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(), List.of(), "PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取", LocalDateTime.now()); } @Override - public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); } + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_FALLBACK", true, false); } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..ebcca60ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java @@ -0,0 +1,202 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.EvergreenTrackingClient.EvergreenTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(8) +public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML"; + private static final String LOADED = "LOADED (FCL) ON VESSEL"; + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM-dd-uuuu", Locale.ENGLISH); + private static final Pattern TABLE = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern ROW = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern CELL = Pattern.compile("(?is)]*>(.*?)"); + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)\\s*$"); + private static final Set REQUIRED_HEADERS = Set.of("箱号", "柜型", "日期", "货柜动态", "地点", "船名航次", "METHOD", "VGM"); + private final EvergreenTrackingClient client; + + public EvergreenCarrierScheduleProviderAdapter(EvergreenTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "EMC".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + EvergreenTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (EvergreenTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } catch (EvergreenParseException exception) { + return new QueryResult(PROVIDER, "MANUAL_REQUIRED", List.of(), List.of(), exception.code, + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, EvergreenTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null) return List.of(); + String container = normalize(containerNo); + TrackingRow row = trackingRow(response.containerHtml(), container); + if (row == null) throw new EvergreenParseException("EMC_CONTAINER_ROW_NOT_FOUND", "长荣 HTML 未找到目标柜行"); + String rawHash = sha256(response.rawBody() == null + ? safe(response.containerHtml()) + safe(response.billHtml()) : response.rawBody()); + List facts = new ArrayList<>(); + if (LOADED.equals(normalizeText(row.status()))) { + LocalDate date = parseDay(row.date()); + if (date == null) throw new EvergreenParseException("EMC_DAY_PARSE_FAILED", "长荣货柜动态日期无法解析"); + add(facts, container, "LOADED_FCL_ON_VESSEL", "LOADED_ON_VESSEL", queriedAt, date, + false, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + String estimatedRaw = estimatedLoadingDate(response.billHtml()); + LocalDate estimated = parseDay(estimatedRaw); + if (estimated != null) { + add(facts, container, "ESTIMATED_LOADING_DATE", "LOADED_ON_VESSEL", queriedAt, estimated, + true, row.location(), row.vesselVoyage(), response.billSuffix(), rawHash); + } + facts.sort(Comparator.comparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())); + return List.copyOf(facts); + } + + static String standardNode(String status) { + return LOADED.equals(normalizeText(status)) ? "LOADED_ON_VESSEL" : null; + } + + private static void add(List facts, String container, String providerNode, String standardNode, + LocalDateTime queriedAt, LocalDate day, boolean estimated, String location, + String vesselVoyage, String billSuffix, String rawHash) { + LocalDateTime eventAt = day.atStartOfDay(); + String rawDate = day.format(DAY).toUpperCase(Locale.ENGLISH); + String reference = "EMC:CNTR:" + container + "|BL_SUFFIX:" + safe(billSuffix) + + "|DATE:" + rawDate + "|PRECISION:DAY|ZONE:Asia/Shanghai"; + String eventId = sha256(container + '|' + providerNode + '|' + rawDate + '|' + + normalizeText(location) + '|' + normalizeText(vesselVoyage)); + facts.add(new Observation(eventId, providerNode, standardNode, queriedAt, + estimated ? eventAt : null, estimated ? null : eventAt, countryCode(location), blank(location), + reference, rawHash)); + } + + static TrackingRow trackingRow(String html, String targetContainer) { + Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) { + List> rows = rows(tables.group(1)); + for (int headerIndex = 0; headerIndex < rows.size(); headerIndex++) { + Map header = header(rows.get(headerIndex)); + if (!header.keySet().containsAll(REQUIRED_HEADERS)) continue; + for (int index = headerIndex + 1; index < rows.size(); index++) { + List cells = rows.get(index); + String container = cell(cells, header.get("箱号")); + if (normalize(container).equals(targetContainer)) { + return new TrackingRow(container, cell(cells, header.get("日期")), + cell(cells, header.get("货柜动态")), cell(cells, header.get("地点")), + cell(cells, header.get("船名航次"))); + } + } + } + } + return null; + } + + static String estimatedLoadingDate(String html) { + for (List row : allRows(html)) { + for (int index = 0; index + 1 < row.size(); index++) { + String label = row.get(index).replaceAll("\\s+", ""); + if ("预计装船日".equals(label) || "預計裝船日".equals(label)) return row.get(index + 1).trim(); + } + } + return null; + } + + private static List> allRows(String html) { + List> values = new ArrayList<>(); Matcher tables = TABLE.matcher(html == null ? "" : html); + while (tables.find()) values.addAll(rows(tables.group(1))); return values; + } + private static List> rows(String table) { + List> values = new ArrayList<>(); Matcher rows = ROW.matcher(table); + while (rows.find()) { + List cells = new ArrayList<>(); Matcher cell = CELL.matcher(rows.group(1)); + while (cell.find()) cells.add(htmlText(cell.group(1))); if (!cells.isEmpty()) values.add(List.copyOf(cells)); + } + return values; + } + private static Map header(List cells) { + Map values = new HashMap<>(); + for (int index = 0; index < cells.size(); index++) values.put(normalizeHeader(cells.get(index)), index); + return values; + } + private static String normalizeHeader(String value) { + String text = value == null ? "" : value.replaceAll("\\s+", "").trim(); + return text.chars().allMatch(ch -> ch < 128) ? text.toUpperCase(Locale.ROOT) : text; + } + private static String cell(List cells, Integer index) { + return index == null || index < 0 || index >= cells.size() ? "" : cells.get(index).trim(); + } + private static String htmlText(String value) { + return HtmlUtils.htmlUnescape(value.replaceAll("(?is)<[^>]+>", " ")).replace('\u00a0', ' ') + .replaceAll("\\s+", " ").trim(); + } + private static LocalDate parseDay(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDate.parse(value.trim(), DAY); } + catch (DateTimeParseException exception) { return null; } + } + private static String countryCode(String location) { + Matcher matcher = COUNTRY_CODE.matcher(location == null ? "" : location.trim().toUpperCase(Locale.ROOT)); + return matcher.find() ? matcher.group(1) : null; + } + private static String normalizeText(String value) { + return value == null ? "" : HtmlUtils.htmlUnescape(value).trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String safe(String value) { return value == null ? "" : value; } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + record TrackingRow(String containerNo, String date, String status, String location, String vesselVoyage) { } + private static final class EvergreenParseException extends RuntimeException { + private final String code; + private EvergreenParseException(String code, String message) { super(message); this.code = code; } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_EMC", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java new file mode 100644 index 000000000..30eed87f4 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java @@ -0,0 +1,151 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class EvergreenTrackingClient { + private static final Pattern INPUT = Pattern.compile("(?is)]*>"); + private static final Pattern ATTRIBUTE = Pattern.compile("(?i)\\b([a-z_:][-a-z0-9_:.]*)\\s*=\\s*([\"'])(.*?)\\2"); + private static final Pattern DISP_INFO = Pattern.compile("(?is)GetDispInfo[^0-9]{0,160}(\\d{12})"); + private static final Pattern BL_QUERY = Pattern.compile("(?i)(?:[?&]|&)(?:BL|bl_no)=(\\d{12})"); + private final Object requestLock = new Object(); + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Evergreen config; + private HttpClient httpClient; + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().evergreen(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("EMC_NOT_CONFIGURED", "长荣公开货柜动态查询未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("EMC_CONTAINER_REQUIRED", "长荣首批自动节点仅支持柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = normalize(request.trackingNo()); + String containerHtml = post("TYPE=CNTR&CNTR=" + encode(container), "CONTAINER"); + String bill = relatedBill(containerHtml); + if (bill == null) manual("EMC_RELATED_BL_MISSING", "长荣柜号结果缺少关联提单,需人工复核"); + String billHtml = post("TYPE=BL&BL=" + encode(bill), "BILL"); + return new TrackingResponse(containerHtml, billHtml, bill.substring(bill.length() - 4), + containerHtml + billHtml); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private String post(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "text/html") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("EMC_" + stage + "_HTTP_" + response.statusCode(), "长荣公开查询拒绝请求"); + } + if (!looksLikeHtml(response.body())) manual("EMC_" + stage + "_NON_HTML", "长荣返回非 HTML 内容"); + return response.body(); + } catch (EvergreenTrackingException exception) { + throw exception; + } catch (HttpTimeoutException exception) { + throw retryable("EMC_" + stage + "_TIMEOUT", "长荣公开查询超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("EMC_" + stage + "_INTERRUPTED", "长荣公开查询被中断", exception); + } catch (IOException exception) { + throw retryable("EMC_" + stage + "_IO_FAILED", "长荣公开查询网络失败", exception); + } + } + + static String relatedBill(String html) { + Matcher inputs = INPUT.matcher(html == null ? "" : html); + while (inputs.find()) { + String name = attribute(inputs.group(), "name"); + String value = HtmlUtils.htmlUnescape(attribute(inputs.group(), "value")); + if ("bl_no".equalsIgnoreCase(name) && value.matches("\\d{12}")) return value; + } + String decoded = HtmlUtils.htmlUnescape(html == null ? "" : html); + Matcher action = DISP_INFO.matcher(decoded); + if (action.find()) return action.group(1); + Matcher query = BL_QUERY.matcher(decoded); + return query.find() ? query.group(1) : null; + } + + private static String attribute(String tag, String expected) { + Matcher attributes = ATTRIBUTE.matcher(tag); + while (attributes.find()) if (expected.equalsIgnoreCase(attributes.group(1))) return attributes.group(3).trim(); + return ""; + } + private static boolean looksLikeHtml(String value) { + String lower = value == null ? "" : value.toLowerCase(Locale.ROOT); + return lower.contains("]"; + } + } + public static final class EvergreenTrackingException extends RuntimeException { + private final String outcome; private final String code; + public EvergreenTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public EvergreenTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..4206d504b --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapter.java @@ -0,0 +1,161 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.HedeTrackingClient.HedeTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +@Order(10) +public class HedeCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "HEDE_PUBLIC_HTML"; + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final Pattern ROW_PATTERN = Pattern.compile( + "]*class=[\\\"'][^\\\"']*read-tr[^\\\"']*[\\\"'][^>]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private static final Pattern CELL_PATTERN = Pattern.compile("]*>([\\s\\S]*?)", + Pattern.CASE_INSENSITIVE); + private final HedeTrackingClient client; + + public HedeCarrierScheduleProviderAdapter(HedeTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "HEDE".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_CONTAINER_REQUIRED", + "合德自动节点当前仅支持按柜号精确解析,提单可能返回多个柜子", queriedAt); + } + try { + HedeTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response.html(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, List.of(), + null, null, queriedAt); + } catch (HedeTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } catch (Exception exception) { + return new QueryResult(PROVIDER, "PERMANENT_FAILURE", List.of(), List.of(), "HEDE_HTML_SCHEMA_CHANGED", + "合德查询 HTML 结构无法解析", queriedAt); + } + } + + List observations(String containerNo, String html) throws Exception { + return observations(containerNo, html, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, String html, LocalDateTime queriedAt) throws Exception { + String normalizedContainer = normalizeIdentifier(containerNo); + List matched = rows(html).stream() + .filter(value -> normalizeIdentifier(value.containerNo()).equals(normalizedContainer)).toList(); + if (matched.isEmpty()) return List.of(); + String rawHash = sha256(html); + List values = new ArrayList<>(); + for (HedeRow row : matched) { + String reference = "HEDE:" + row.containerNo() + ':' + row.vessel() + '/' + row.voyage(); + addEstimated(values, row, "ESTIMATED_DEPARTURE", "DEPARTED_ORIGIN_PORT", + row.estimatedDeparture(), queriedAt, reference, rawHash); + addEstimated(values, row, "ESTIMATED_ARRIVAL", "ARRIVED_DESTINATION_PORT", + row.estimatedArrival(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_IN", "AT_ORIGIN_PORT", row.fullGateIn(), queriedAt, reference, rawHash); + addActual(values, row, "LOADED", "LOADED_ON_VESSEL", row.loadedAt(), queriedAt, reference, rawHash); + addActual(values, row, "DISCHARGED", "DISCHARGED_FROM_VESSEL", row.dischargedAt(), queriedAt, reference, rawHash); + addActual(values, row, "FULL_GATE_OUT", "PICKED_UP", row.fullGateOut(), queriedAt, reference, rawHash); + addActual(values, row, "EMPTY_RETURNED", "EMPTY_RETURNED", row.emptyReturnedAt(), queriedAt, reference, rawHash); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + private static void addActual(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime actualAt = parseDate(timestamp); + if (actualAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + null, actualAt, null, null, reference, rawHash)); + } + + private static void addEstimated(List values, HedeRow row, String providerNode, + String standardNode, String timestamp, LocalDateTime queriedAt, + String reference, String rawHash) { + LocalDateTime estimatedAt = parseDate(timestamp); + if (estimatedAt == null) return; + String eventIdentity = row.containerNo() + '|' + providerNode + '|' + timestamp; + values.add(new Observation(sha256(eventIdentity), providerNode, standardNode, queriedAt, + estimatedAt, null, null, null, reference, rawHash)); + } + + static List rows(String html) { + List rows = new ArrayList<>(); + Matcher rowMatcher = ROW_PATTERN.matcher(html == null ? "" : html); + while (rowMatcher.find()) { + List cells = new ArrayList<>(); + Matcher cellMatcher = CELL_PATTERN.matcher(rowMatcher.group(1)); + while (cellMatcher.find()) cells.add(htmlText(cellMatcher.group(1))); + if (cells.size() >= 12) rows.add(row(cells)); + } + return List.copyOf(rows); + } + + private static String htmlText(String value) { + return value.replaceAll("(?is)<[^>]+>", " ") + .replace(" ", " ").replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").trim().replaceAll("\\s+", " "); + } + + private static HedeRow row(List cells) { + return new HedeRow(cells.get(0), cells.get(1), cells.get(2), cells.get(3), cells.get(4), cells.get(5), + cells.get(6), cells.get(7), cells.get(8), cells.get(9), cells.get(10), cells.get(11)); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + try { return LocalDateTime.parse(value.trim(), DATE_TIME); } + catch (DateTimeParseException exception) { return null; } + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_HEDE", client.configured(), client.configured()); + } + + record HedeRow(String billNo, String containerNo, String vessel, String voyage, + String estimatedDeparture, String estimatedArrival, String emptyGateOut, + String fullGateIn, String loadedAt, String dischargedAt, + String fullGateOut, String emptyReturnedAt) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java new file mode 100644 index 000000000..e223970f2 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java @@ -0,0 +1,123 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +@Component +public class HedeTrackingClient { + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + private IntegrationProperties.CarrierSchedule.Hede config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().hede(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new HedeTrackingException("HEDE_NOT_CONFIGURED", false, "合德网页查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + String trackingType = request.trackingType().trim().toUpperCase(Locale.ROOT); + String billNo = "BILL_OF_LADING".equals(trackingType) ? request.trackingNo().trim() : ""; + String containerNo = "CONTAINER_NO".equals(trackingType) ? request.trackingNo().trim() : ""; + if (billNo.isEmpty() && containerNo.isEmpty()) { + throw new HedeTrackingException("HEDE_TRACKING_TYPE_UNSUPPORTED", false, + "合德仅支持提单号或柜号查询"); + } + String form = "billno=" + encode(billNo) + "&cntr=" + encode(containerNo) + + "&selecttype=1&resultlist=1¶mtype=0"; + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode()); + return new TrackingResponse(response.body()); + } catch (HedeTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "合德查询被中断", exception); + } catch (IOException exception) { + throw new HedeTrackingException("HEDE_IO_FAILED", true, "合德查询网络失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new HedeTrackingException("HEDE_INTERRUPTED", true, "等待合德查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new HedeTrackingException("HEDE_RATE_LIMITED", true, "合德查询触发访问频率限制"); + } + throw new HedeTrackingException("HEDE_HTTP_" + status, status >= 500, + "合德查询返回异常 HTTP 状态:" + status); + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + public record TrackingResponse(String html) { } + + public static final class HedeTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public HedeTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public HedeTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..c78f41108 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapter.java @@ -0,0 +1,220 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.MatsonTrackingClient.MatsonTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +@Component +@Order(0) +public class MatsonCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "MATSON_PUBLIC_TRACKING"; + private final MatsonTrackingClient client; + + public MatsonCarrierScheduleProviderAdapter(MatsonTrackingClient client) { + this.client = client; + } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "MATSON".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + try { + MatsonTrackingClient.TrackingResponse response = client.query(request); + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + List observations = observations(request, response.payload(), response.rawBody(), queriedAt); + List exceptions = exceptions(request, response.payload(), response.rawBody(), queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() && exceptions.isEmpty() ? "NO_CHANGE" : "SUCCESS", + observations, exceptions, + null, null, queriedAt); + } catch (MatsonTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), LocalDateTime.now(ZoneOffset.UTC)); + } + } + + List observations(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (events.isArray()) for (JsonNode event : events) { + long epochMillis = event.path("date").asLong(0); + String rawStatus = text(event, "status", "").trim(); + String standardNode = standardNode(rawStatus); + if (epochMillis <= 0 || standardNode == null) continue; + LocalDateTime actualAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC); + String locationName = text(event, "location", "").trim(); + String locationCode = locationCode(payload, locationName, standardNode); + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + normalizeStatus(rawStatus) + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new Observation(sha256(eventIdentity), truncate(normalizeStatus(rawStatus), 64), standardNode, + queriedAt, null, actualAt, blankToNull(locationCode), blankToNull(locationName), + sourceReference, rawHash)); + } + LocalDateTime eta = parseDate(payload.get("vesselETA")); + if (eta != null) { + String eventIdentity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + "|VESSEL_ETA|" + eta; + values.add(new Observation(sha256(eventIdentity), "VESSEL_ETA", "ARRIVED_DESTINATION_PORT", + queriedAt, eta, null, blankToNull(text(payload, "destCode", "")), + blankToNull(text(payload, "destPort", "")), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(Observation::observedAt)); + return List.copyOf(values); + } + + List exceptions(QueryRequest request, JsonNode payload, String rawBody, + LocalDateTime queriedAt) { + if (payload == null || !payload.isObject()) return List.of(); + if ("CONTAINER_NO".equalsIgnoreCase(request.trackingType()) && !matchesContainer(request.trackingNo(), payload)) { + return List.of(); + } + String rawHash = sha256(rawBody == null ? payload.toString() : rawBody); + String sourceReference = "MATSON:" + text(payload, "bookingNumber", request.trackingNo()); + List values = new ArrayList<>(); + JsonNode events = payload.path("events"); + if (!events.isArray()) return List.of(); + for (JsonNode event : events) { + String rawStatus = text(event, "status", "").trim(); + String status = normalizeStatus(rawStatus); + String exceptionCode = exceptionCode(status); + long epochMillis = event.path("date").asLong(0); + if (exceptionCode == null || epochMillis <= 0) continue; + String locationName = text(event, "location", "").trim(); + String identity = request.trackingNo().trim().toUpperCase(Locale.ROOT) + '|' + epochMillis + '|' + + status + '|' + locationName.toUpperCase(Locale.ROOT); + values.add(new ExceptionObservation(sha256(identity), truncate(rawStatus, 128), exceptionCode, true, + queriedAt, blankToNull(locationCode(payload, locationName, "PICKUP_READY")), + blankToNull(locationName), sourceReference, rawHash)); + } + values.sort(Comparator.comparing(ExceptionObservation::observedAt)); + return List.copyOf(values); + } + + static String standardNode(String status) { + String value = normalizeStatus(status); + return switch (value) { + case "INGATE" -> "AT_ORIGIN_PORT"; + case "LOAD TO VESSEL" -> "LOADED_ON_VESSEL"; + case "VESSEL DEPARTURE" -> "DEPARTED_ORIGIN_PORT"; + case "IN TRANSIT" -> "IN_TRANSIT"; + case "VESSEL ARRIVAL", "ARRIVED AT DESTINATION" -> "ARRIVED_DESTINATION_PORT"; + case "DISCHARGE FROM VESSEL" -> "DISCHARGED_FROM_VESSEL"; + case "AVAILABLE FOR PICKUP" -> "PICKUP_READY"; + case "OUTGATE" -> "PICKED_UP"; + case "EMPTY RETURN", "EMPTY RETURNED" -> "EMPTY_RETURNED"; + default -> null; + }; + } + + static String exceptionCode(String status) { + return "CONTAINER ON HOLD - CUSTOMS CLEARANCE".equals(normalizeStatus(status)) ? "CUSTOMS_HOLD" : null; + } + + private static boolean matchesContainer(String requested, JsonNode payload) { + String actual = text(payload, "containerNumber", "") + text(payload, "checkDigit", ""); + return normalizeIdentifier(requested).equals(normalizeIdentifier(actual)); + } + + private static String locationCode(JsonNode payload, String location, String standardNode) { + String normalized = location.toUpperCase(Locale.ROOT); + String originName = text(payload, "originPort", "").toUpperCase(Locale.ROOT); + String loadPort = text(payload, "loadPort", "").toUpperCase(Locale.ROOT); + String destinationName = text(payload, "destPort", "").toUpperCase(Locale.ROOT); + String dischargePort = text(payload, "dischargePort", "").toUpperCase(Locale.ROOT); + if (!normalized.isBlank() && (normalized.equals(originName) || normalized.equals(loadPort))) { + return text(payload, "originCode", ""); + } + if (!normalized.isBlank() && (normalized.equals(destinationName) || normalized.equals(dischargePort))) { + return text(payload, "destCode", ""); + } + if ("ARRIVED_DESTINATION_PORT".equals(standardNode) + || "DISCHARGED_FROM_VESSEL".equals(standardNode) || "PICKUP_READY".equals(standardNode) + || "PICKED_UP".equals(standardNode) || "EMPTY_RETURNED".equals(standardNode)) { + return text(payload, "destCode", ""); + } + return ""; + } + + private static String normalizeStatus(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + + private static String text(JsonNode node, String field, String fallback) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? fallback : value.asText(fallback); + } + + private static LocalDateTime parseDate(JsonNode value) { + if (value == null || value.isNull()) return null; + if (value.isNumber()) { + long epoch = value.asLong(); + if (epoch <= 0) return null; + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } + String text = value.asText("").trim(); + if (text.isEmpty()) return null; + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE, + DateTimeFormatter.ofPattern("MM/dd/yy"), DateTimeFormatter.ofPattern("MM/dd/yyyy"))) { + try { return LocalDate.parse(text, formatter).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_MATSON", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java new file mode 100644 index 000000000..470e86c68 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java @@ -0,0 +1,172 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class MatsonTrackingClient { + private static final Pattern TTR_PATTERN = Pattern.compile( + "id=[\\\"']ttr[\\\"'][^>]*value=[\\\"']([^\\\"']+)[\\\"']", Pattern.CASE_INSENSITIVE); + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Matson config; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().matson(); + } + + public boolean configured() { + return config != null && config.configured(); + } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) { + throw new MatsonTrackingException("MATSON_NOT_CONFIGURED", false, + "美森公开查询能力未启用或地址未配置"); + } + synchronized (requestLock) { + waitForRequestSlot(); + try { + return execute(request); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private TrackingResponse execute(QueryRequest request) { + CookieManager cookies = new CookieManager(null, CookiePolicy.ACCEPT_ALL); + HttpClient client = HttpClient.newBuilder() + .cookieHandler(cookies) + .connectTimeout(config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + try { + HttpResponse page = client.send(HttpRequest.newBuilder(config.pageUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .GET().build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(page.statusCode(), "MATSON_PAGE"); + Matcher matcher = TTR_PATTERN.matcher(page.body()); + if (!matcher.find() || matcher.group(1).isBlank()) { + throw new MatsonTrackingException("MATSON_TTR_MISSING", true, + "美森查询页未返回动态校验参数"); + } + + String form = "container_trackingnumber=" + encode(request.trackingNo().trim()) + + "&trackingVal=" + trackingValue(request.trackingType()) + + "&ttr=" + encode(matcher.group(1)); + HttpResponse response = client.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Origin", origin(config.pageUrl())) + .header("Referer", config.pageUrl().toString()) + .header("X-Requested-With", "XMLHttpRequest") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + ensureHttp(response.statusCode(), "MATSON_QUERY"); + JsonNode payload = objectMapper.readTree(response.body()); + return new TrackingResponse(payload, response.body()); + } catch (MatsonTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "美森查询被中断", exception); + } catch (IOException exception) { + throw new MatsonTrackingException("MATSON_IO_FAILED", true, "美森查询网络或响应解析失败", exception); + } + } + + private void waitForRequestSlot() { + long waitMillis = nextRequestAtMillis - System.currentTimeMillis(); + if (waitMillis <= 0) return; + try { + Thread.sleep(waitMillis); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new MatsonTrackingException("MATSON_INTERRUPTED", true, "等待美森查询限流窗口时被中断", exception); + } + } + + private static void ensureHttp(int status, String stage) { + if (status >= 200 && status < 300) return; + if (status == 429) { + throw new MatsonTrackingException("MATSON_RATE_LIMITED", true, "美森查询触发访问频率限制"); + } + if (status == 403) { + throw new MatsonTrackingException("MATSON_SESSION_REJECTED", true, + "美森拒绝当前网页会话,请稍后重新获取动态参数"); + } + throw new MatsonTrackingException(stage + "_HTTP_" + status, status >= 500, + "美森查询返回异常 HTTP 状态:" + status); + } + + private static String trackingValue(String trackingType) { + return switch (trackingType.trim().toUpperCase(Locale.ROOT)) { + case "CONTAINER_NO" -> "tckconta"; + case "BOOKING_NO" -> "tckbok"; + case "BILL_OF_LADING" -> "tckbil"; + default -> throw new MatsonTrackingException("MATSON_TRACKING_TYPE_UNSUPPORTED", false, + "美森不支持该跟踪类型:" + trackingType); + }; + } + + private static String origin(URI uri) { + int port = uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + (port < 0 ? "" : ":" + port); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + public record TrackingResponse(JsonNode payload, String rawBody) { } + + public static final class MatsonTrackingException extends RuntimeException { + private final String code; + private final boolean retryable; + + public MatsonTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + + public MatsonTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..72af144ee --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapter.java @@ -0,0 +1,154 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.SmlTrackingClient.SmlTrackingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(5) +public class SmlCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "SML_PUBLIC_TRACKING"; + private static final Map NODE_MAP = Map.of( + "FOTMAD", "AT_ORIGIN_PORT", + "FLVMLO", "LOADED_ON_VESSEL", + "FLVMDO", "DEPARTED_ORIGIN_PORT", + "FUVMAD", "ARRIVED_DESTINATION_PORT", + "FUVMBD", "ARRIVED_DESTINATION_PORT", + "FUVMUD", "DISCHARGED_FROM_VESSEL", + "MITYAD", "EMPTY_RETURNED"); + private final SmlTrackingClient client; + + public SmlCarrierScheduleProviderAdapter(SmlTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "SML".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + SmlTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (SmlTrackingException exception) { + return new QueryResult(PROVIDER, exception.retryable() ? "RETRYABLE_FAILURE" : "PERMANENT_FAILURE", + List.of(), List.of(), exception.code(), exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, SmlTrackingClient.TrackingResponse response) { + return observations(containerNo, response, LocalDateTime.now(ZoneOffset.UTC)); + } + + private List observations(String containerNo, SmlTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.timeline() == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? response.timeline().toString() : response.rawBody()); + String reference = "SML:" + response.bookingNo() + ':' + response.copNo(); + Map facts = new LinkedHashMap<>(); + collect(response.timeline(), containerNo, reference, rawHash, queriedAt, facts); + return facts.values().stream().sorted(Comparator.comparing(Observation::observedAt)).toList(); + } + + private static void collect(JsonNode node, String containerNo, String reference, String rawHash, + LocalDateTime queriedAt, + Map facts) { + if (node == null) return; + if (node.isObject()) { + String providerNode = directText(node, "actStsMapgCd", "actCd", "actStsCd", "eventCode"); + String actType = directText(node, "actTpCd", "actualType"); + String standardNode = providerNode == null ? null : NODE_MAP.get(providerNode.toUpperCase(Locale.ROOT)); + LocalDateTime eventAt = parseDate(directText(node, "eventDt", "actDt", "skdDt", "eventDate", "date")); + if (standardNode != null && eventAt != null && ("A".equalsIgnoreCase(actType) || "E".equalsIgnoreCase(actType))) { + String locationCode = directText(node, "locCd", "locationCode", "portCd"); + String locationName = directText(node, "locNm", "locationName", "portNm"); + String identity = normalize(containerNo) + '|' + providerNode.toUpperCase(Locale.ROOT) + '|' + + actType.toUpperCase(Locale.ROOT) + '|' + eventAt + '|' + normalize(locationCode) + '|' + + normalize(locationName); + String eventId = sha256(identity); + boolean actual = "A".equalsIgnoreCase(actType); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode.toUpperCase(Locale.ROOT), + standardNode, queriedAt, + actual ? null : eventAt, actual ? eventAt : null, blank(locationCode), blank(locationName), + reference, rawHash)); + } + Iterator children = node.elements(); + while (children.hasNext()) collect(children.next(), containerNo, reference, rawHash, queriedAt, facts); + } else if (node.isArray()) for (JsonNode child : node) { + collect(child, containerNo, reference, rawHash, queriedAt, facts); + } + } + + static String standardNode(String providerCode) { + return providerCode == null ? null : NODE_MAP.get(providerCode.trim().toUpperCase(Locale.ROOT)); + } + + private static String directText(JsonNode node, String... names) { + for (String name : names) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (field.getKey().equalsIgnoreCase(name) && field.getValue().isValueNode()) { + String value = field.getValue().asText("").trim(); + if (!value.isEmpty()) return value; + } + } + } + return null; + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { + long epoch = Long.parseLong(text); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epoch < 10_000_000_000L ? epoch * 1000 : epoch), + ZoneOffset.UTC); + } catch (NumberFormatException ignored) { } + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), DateTimeFormatter.ofPattern("yyyyMMddHHmm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + return null; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE_SML", client.configured(), client.configured()); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java new file mode 100644 index 000000000..575b9a68c --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java @@ -0,0 +1,165 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.net.http.HttpTimeoutException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; + +@Component +public class SmlTrackingClient { + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Sml config; + private HttpClient httpClient; + private final Object requestLock = new Object(); + private long nextRequestAtMillis; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().sml(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? java.time.Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) throw new SmlTrackingException("SML_NOT_CONFIGURED", false, "SML 查询能力未启用"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + throw new SmlTrackingException("SML_CONTAINER_REQUIRED", false, "SML 自动节点只按柜号查询"); + } + synchronized (requestLock) { + waitForSlot(); + try { + String container = request.trackingNo().trim(); + JsonResponse lookup = call("f_cmd=122&cntr_no=" + encode(container) + "&cust_cd=", "LOOKUP"); + validateBusinessResult(lookup.payload()); + String bookingNo = findText(lookup.payload(), "bkgNo", "bkg_no"); + String copNo = findText(lookup.payload(), "copNo", "cop_no"); + if (bookingNo == null || copNo == null) { + throw new SmlTrackingException("SML_BUSINESS_KEYS_MISSING", false, + "SML 查询结果缺少 bkgNo 或 copNo"); + } + JsonResponse timeline = call("f_cmd=125&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo) + "&cop_no=" + encode(copNo), "TIMELINE"); + validateBusinessResult(timeline.payload()); + JsonResponse schedule = call("f_cmd=124&bkg_no=" + encode(bookingNo), "SCHEDULE"); + JsonResponse clearance = call("f_cmd=126&cntr_no=" + encode(container) + "&bkg_no=" + + encode(bookingNo), "CLEARANCE"); + return new TrackingResponse(bookingNo, copNo, timeline.payload(), schedule.payload(), + clearance.payload(), lookup.raw() + timeline.raw() + schedule.raw() + clearance.raw()); + } finally { + nextRequestAtMillis = System.currentTimeMillis() + config.effectiveMinRequestInterval().toMillis(); + } + } + } + + private JsonResponse call(String form, String stage) { + try { + HttpResponse response = httpClient.send(HttpRequest.newBuilder(config.queryUrl()) + .timeout(config.effectiveRequestTimeout()) + .header("Accept", "application/json, text/javascript, */*; q=0.01") + .header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 429) throw new SmlTrackingException("SML_RATE_LIMITED", true, "SML 访问限流"); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new SmlTrackingException("SML_" + stage + "_HTTP_" + response.statusCode(), + response.statusCode() >= 500, "SML 查询返回异常 HTTP 状态:" + response.statusCode()); + } + try { return new JsonResponse(objectMapper.readTree(response.body()), response.body()); } + catch (Exception exception) { + throw new SmlTrackingException("SML_" + stage + "_NON_JSON", true, + "SML 查询返回 HTML 或无效 JSON", exception); + } + } catch (SmlTrackingException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "SML 查询被中断", exception); + } catch (HttpTimeoutException exception) { + throw new SmlTrackingException("SML_" + stage + "_TIMEOUT", true, "SML 查询超时", exception); + } catch (IOException exception) { + throw new SmlTrackingException("SML_IO_FAILED", true, "SML 查询网络失败", exception); + } + } + + private static void validateBusinessResult(JsonNode payload) { + if (payload == null || payload.isNull()) throw new SmlTrackingException("SML_EMPTY_RESULT", false, "SML 返回空结果"); + String result = findText(payload, "TRANS_RESULT_KEY", "transResultKey"); + if (result != null && !"S".equalsIgnoreCase(result)) { + throw new SmlTrackingException("SML_BUSINESS_FAILED", false, "SML 业务查询失败:" + result); + } + Long count = findLong(payload, "count", "totalCount"); + if (count != null && count == 0L) throw new SmlTrackingException("SML_NOT_FOUND", false, "SML 未找到业务对象"); + } + + static String findText(JsonNode node, String... names) { + if (node == null) return null; + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + var field = fields.next(); + for (String name : names) if (field.getKey().equalsIgnoreCase(name) + && field.getValue().isValueNode() && !field.getValue().asText().isBlank()) { + return field.getValue().asText().trim(); + } + String nested = findText(field.getValue(), names); + if (nested != null) return nested; + } + } else if (node.isArray()) for (JsonNode child : node) { + String nested = findText(child, names); if (nested != null) return nested; + } + return null; + } + + private static Long findLong(JsonNode node, String... names) { + String value = findText(node, names); + if (value == null) return null; + try { return Long.parseLong(value); } + catch (NumberFormatException ignored) { return null; } + } + + private void waitForSlot() { + long wait = nextRequestAtMillis - System.currentTimeMillis(); + if (wait <= 0) return; + try { Thread.sleep(wait); } + catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new SmlTrackingException("SML_INTERRUPTED", true, "等待 SML 限流窗口时被中断", exception); + } + } + + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record JsonResponse(JsonNode payload, String raw) { } + public record TrackingResponse(String bookingNo, String copNo, JsonNode timeline, JsonNode schedule, + JsonNode clearance, String rawBody) { } + + public static final class SmlTrackingException extends RuntimeException { + private final String code; private final boolean retryable; + public SmlTrackingException(String code, boolean retryable, String message) { + super(message); this.code = code; this.retryable = retryable; + } + public SmlTrackingException(String code, boolean retryable, String message, Throwable cause) { + super(message, cause); this.code = code; this.retryable = retryable; + } + public String code() { return code; } + public boolean retryable() { return retryable; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java new file mode 100644 index 000000000..3d7e531ef --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapter.java @@ -0,0 +1,199 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.ZimTrackingClient.ContainerEvent; +import com.baiyue.integration.provider.ZimTrackingClient.RouteDetail; +import com.baiyue.integration.provider.ZimTrackingClient.ZimTrackingException; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +@Order(7) +public class ZimCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { + private static final String PROVIDER = "ZIM_OFFICIAL_CARGO_TRACKING"; + private static final Map EVENT_MAP = Map.ofEntries( + Map.entry("EXPORT GATE-IN FROM CUSTOMER TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("EXPORT GATE-IN TO PORT OF LOADING", "AT_ORIGIN_PORT"), + Map.entry("CONTAINER WAS LOADED AT TRANSSHIPMENT PORT TO TRANSSHIPMENT PORT", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM FIRST TRANSSHIPMENT PORT TO NEXT TRANSSHIPMENT PORT", "IN_TRANSIT"), + Map.entry("CONTAINER LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING", "LOADED_ON_VESSEL"), + Map.entry("CONTAINER WAS LOADED AT PORT OF LOADING TO PORT OF DISCHARGE", "LOADED_ON_VESSEL"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING", "DEPARTED_ORIGIN_PORT"), + Map.entry("VESSEL DEPARTURE FROM PORT OF LOADING TO PORT OF DISCHARGE", "DEPARTED_ORIGIN_PORT")); + private final ZimTrackingClient client; + + public ZimCarrierScheduleProviderAdapter(ZimTrackingClient client) { this.client = client; } + + @Override + public boolean supports(String carrierCode) { + return client.configured() && carrierCode != null && "ZIM".equals(carrierCode.trim().toUpperCase(Locale.ROOT)); + } + + @Override + public QueryResult query(QueryRequest request) { + LocalDateTime queriedAt = LocalDateTime.now(ZoneOffset.UTC); + try { + ZimTrackingClient.TrackingResponse response = client.query(request); + List observations = observations(request.trackingNo(), response, queriedAt); + return new QueryResult(PROVIDER, observations.isEmpty() ? "NO_CHANGE" : "SUCCESS", observations, + List.of(), null, null, queriedAt); + } catch (ZimTrackingException exception) { + return new QueryResult(PROVIDER, exception.outcome(), List.of(), List.of(), exception.code(), + exception.getMessage(), queriedAt); + } + } + + List observations(String containerNo, ZimTrackingClient.TrackingResponse response, + LocalDateTime queriedAt) { + if (response == null || response.payload() == null || response.payload().bkBlDetails() == null) return List.of(); + String normalizedContainer = normalize(containerNo); + ZimTrackingClient.ContainerDetail unit = response.payload().bkBlDetails().safeContainers().stream() + .filter(value -> normalizedContainer.equals(normalize(value.containerNumber()))) + .findFirst().orElse(null); + if (unit == null) return List.of(); + String rawHash = sha256(response.rawBody() == null ? normalizedContainer : response.rawBody()); + String reference = sourceReference(response.payload(), normalizedContainer); + Map facts = new LinkedHashMap<>(); + for (ContainerEvent event : unit.safeEvents()) { + String eventName = normalizeEvent(event.eventName()); + String standardNode = EVENT_MAP.get(eventName); + LocalDateTime actualAt = parseDate(first(event.activityDateTz(), event.activityDate())); + if (standardNode == null || actualAt == null) continue; + add(facts, normalizedContainer, truncate(eventName, 64), standardNode, queriedAt, null, actualAt, + blank(event.portCode()), locationName(event.port(), event.country()), reference, rawHash, + event.vesselName(), event.voyage(), event.leg()); + } + List routes = response.payload().safeRoutes(); + RouteDetail firstSailing = routes.stream() + .filter(value -> parseDate(first(value.sailingDateTz(), value.sailingDateDT())) != null) + .findFirst().orElse(null); + if (firstSailing != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POL_SAILING_PLAN", "DEPARTED_ORIGIN_PORT", + parseDate(first(firstSailing.sailingDateTz(), firstSailing.sailingDateDT())), queriedAt, + firstSailing, reference, rawHash); + } + RouteDetail lastArrival = null; + for (int index = routes.size() - 1; index >= 0; index--) { + RouteDetail candidate = routes.get(index); + if (parseDate(first(candidate.arrivalDateTz(), candidate.arrivalDateDT())) != null) { + lastArrival = candidate; break; + } + } + if (lastArrival != null) { + addEstimated(facts, normalizedContainer, "ROUTE_POD_ARRIVAL_PLAN", "ARRIVED_DESTINATION_PORT", + parseDate(first(lastArrival.arrivalDateTz(), lastArrival.arrivalDateDT())), queriedAt, + lastArrival, reference, rawHash); + } + if (response.payload().finalETA() != null) { + addEstimated(facts, normalizedContainer, "FINAL_ETA", "ARRIVED_DESTINATION_PORT", + parseDate(first(response.payload().finalETA().etaValue(), response.payload().finalETA().etaValueDate())), + queriedAt, null, reference, rawHash); + } + return facts.values().stream().sorted(Comparator.comparing(Observation::standardNode) + .thenComparing(value -> value.actualAt() == null ? value.estimatedAt() : value.actualAt())).toList(); + } + + static String standardNode(String eventName) { + return EVENT_MAP.get(normalizeEvent(eventName)); + } + + private static void addEstimated(Map facts, String container, String providerNode, + String standardNode, LocalDateTime estimatedAt, LocalDateTime queriedAt, + RouteDetail route, String reference, String rawHash) { + if (estimatedAt == null) return; + add(facts, container, providerNode, standardNode, queriedAt, estimatedAt, null, + route == null ? null : blank(route.portCode()), + route == null ? null : locationName(route.port(), route.country()), reference, rawHash, + route == null ? null : route.vesselName(), route == null ? null : route.voyage(), + route == null ? null : route.leg()); + } + + private static void add(Map facts, String container, String providerNode, + String standardNode, LocalDateTime queriedAt, LocalDateTime estimatedAt, + LocalDateTime actualAt, String locationCode, String locationName, + String reference, String rawHash, String vesselName, String voyage, String leg) { + LocalDateTime eventAt = actualAt == null ? estimatedAt : actualAt; + String eventId = sha256(container + '|' + providerNode + '|' + eventAt + '|' + + normalize(locationCode) + '|' + normalize(locationName) + '|' + clean(vesselName) + '|' + + clean(voyage) + '|' + clean(leg)); + facts.putIfAbsent(eventId, new Observation(eventId, providerNode, standardNode, queriedAt, + estimatedAt, actualAt, locationCode, locationName, reference, rawHash)); + } + + private static String first(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + return ""; + } + + private static String sourceReference(ZimTrackingClient.TracingPayload payload, String container) { + StringBuilder value = new StringBuilder("ZIM:CONTAINER:").append(container); + if (!clean(payload.bookingReference()).isEmpty()) value.append("|BOOKING:").append(clean(payload.bookingReference())); + if (!clean(payload.blReference()).isEmpty()) value.append("|BL:").append(clean(payload.blReference())); + return truncate(value.toString(), 500); + } + + private static String locationName(String port, String country) { + String name = clean(port); String nation = clean(country); + return blank(name + (name.isEmpty() || nation.isEmpty() ? "" : ", ") + nation); + } + + private static String normalizeEvent(String value) { + return value == null ? "" : value.trim().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private static LocalDateTime parseDate(String value) { + if (value == null || value.isBlank()) return null; + String text = value.trim(); + try { return LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + try { return LocalDateTime.ofInstant(OffsetDateTime.parse(text).toInstant(), ZoneOffset.UTC); } + catch (DateTimeParseException ignored) { } + for (DateTimeFormatter formatter : List.of(DateTimeFormatter.ISO_LOCAL_DATE_TIME, + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))) { + try { return LocalDateTime.parse(text, formatter); } + catch (DateTimeParseException ignored) { } + } + try { return LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE).atStartOfDay(); } + catch (DateTimeParseException ignored) { } + return null; + } + + private static String clean(String value) { return value == null ? "" : value.trim(); } + private static String blank(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String truncate(String value, int max) { return value.length() <= max ? value : value.substring(0, max); } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String sha256(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (Exception exception) { throw new IllegalStateException(exception); } + } + + @Override + public ProviderStatus status() { + return ProviderStatus.of("CARRIER_SCHEDULE_ZIM", client.configured(), client.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java new file mode 100644 index 000000000..f56a6b0dd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java @@ -0,0 +1,207 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.Semaphore; + +@Component +public class ZimTrackingClient { + private static final Duration TOKEN_REFRESH_SKEW = Duration.ofMinutes(1); + @Resource private IntegrationProperties properties; + @Resource private ObjectMapper objectMapper; + private IntegrationProperties.CarrierSchedule.Zim config; + private HttpClient httpClient; + private Semaphore concurrency; + private final Object tokenLock = new Object(); + private volatile AccessToken cachedToken; + + @PostConstruct + void initialize() { + config = properties.carrierSchedule() == null ? null : properties.carrierSchedule().zim(); + httpClient = HttpClient.newBuilder() + .connectTimeout(config == null ? Duration.ofSeconds(10) : config.effectiveConnectTimeout()) + .followRedirects(HttpClient.Redirect.NORMAL).build(); + concurrency = new Semaphore(config == null ? 1 : config.effectiveMaxConcurrency()); + } + + public boolean configured() { return config != null && config.configured(); } + + public TrackingResponse query(QueryRequest request) { + if (!configured()) manual("ZIM_OFFICIAL_API_NOT_CONFIGURED", "ZIM Tracing API 未启用或缺少授权配置"); + if (!"CONTAINER_NO".equalsIgnoreCase(request.trackingType())) { + manual("ZIM_CONTAINER_REQUIRED", "ZIM 首批已验证契约仅支持柜号查询"); + } + boolean acquired = false; + try { + concurrency.acquire(); acquired = true; + return execute(normalize(request.trackingNo()), false); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_INTERRUPTED", "ZIM Tracing API 查询被中断", exception); + } finally { + if (acquired) concurrency.release(); + } + } + + private TrackingResponse execute(String container, boolean refreshedAfterUnauthorized) { + String token = accessToken(false); + HttpResponse response = send(HttpRequest.newBuilder(tracingUri(container)) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Ocp-Apim-Subscription-Key", config.subscriptionKey()) + .header("Authorization", "bearer " + token).GET().build(), "TRACING"); + if (response.statusCode() == 401 && !refreshedAfterUnauthorized) { + invalidateToken(token); return execute(container, true); + } + if (response.statusCode() == 401 || response.statusCode() == 403) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 授权或来源环境校验失败"); + } + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_HTTP_" + response.statusCode(), "ZIM Tracing API 拒绝查询"); + } + TracingPayload payload = tracingPayload(response.body()); + validateBusinessResult(payload, container); + return new TrackingResponse(payload, response.body()); + } + + private String accessToken(boolean forceRefresh) { + AccessToken current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + synchronized (tokenLock) { + current = cachedToken; + if (!forceRefresh && current != null && current.usableAt(Instant.now())) return current.value(); + String form = "grant_type=client_credentials&client_id=" + encode(config.clientId()) + + "&client_secret=" + encode(config.clientSecret()) + "&scope=tracing"; + HttpResponse response = send(HttpRequest.newBuilder(config.authorizationUrl()) + .timeout(config.effectiveRequestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8)).build(), "AUTH"); + if (response.statusCode() == 429 || response.statusCode() >= 500) { + throw retryable("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 服务暂时不可用", null); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + manual("ZIM_AUTH_HTTP_" + response.statusCode(), "ZIM OAuth 客户端授权失败"); + } + JsonNode payload = json(response.body(), "ZIM_AUTH_NON_JSON"); + String value = payload.path("access_token").asText("").trim(); + if (value.isEmpty()) manual("ZIM_AUTH_TOKEN_MISSING", "ZIM OAuth 响应缺少 access_token"); + long expiresIn = Math.max(60L, payload.path("expires_in").asLong(3600L)); + cachedToken = new AccessToken(value, Instant.now().plusSeconds(expiresIn)); + return value; + } + } + + private HttpResponse send(HttpRequest request, String stage) { + try { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); } + catch (HttpTimeoutException exception) { + throw retryable("ZIM_" + stage + "_TIMEOUT", "ZIM 官方 API 请求超时", exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw retryable("ZIM_" + stage + "_INTERRUPTED", "ZIM 官方 API 请求被中断", exception); + } catch (IOException exception) { + throw retryable("ZIM_" + stage + "_IO_FAILED", "ZIM 官方 API 网络失败", exception); + } + } + + private JsonNode json(String body, String code) { + try { return objectMapper.readTree(body); } + catch (Exception exception) { throw retryable(code, "ZIM 官方 API 返回无效 JSON", exception); } + } + + private TracingPayload tracingPayload(String body) { + try { return objectMapper.readValue(body, TracingPayload.class); } + catch (Exception exception) { throw retryable("ZIM_TRACING_NON_JSON", "ZIM Tracing V2 返回无效 JSON", exception); } + } + + private void validateBusinessResult(TracingPayload payload, String requestedContainer) { + if (payload == null || payload.bkBlDetails() == null) manual("ZIM_EMPTY_RESULT", "ZIM 返回空业务对象"); + List containers = payload.bkBlDetails().safeContainers(); + boolean matched = containers.stream().anyMatch(value -> requestedContainer.equals(normalize(value.containerNumber()))); + if (containers.isEmpty() || !matched) manual("ZIM_NOT_FOUND", "ZIM 未返回匹配柜号的业务对象"); + } + + private URI tracingUri(String container) { + return URI.create(config.tracingBaseUrl().toString().replaceAll("/+$", "") + '/' + encode(container)); + } + private void invalidateToken(String usedToken) { + synchronized (tokenLock) { + if (cachedToken != null && cachedToken.value().equals(usedToken)) cachedToken = null; + } + } + + private static void manual(String code, String message) { + throw new ZimTrackingException("MANUAL_REQUIRED", code, message); + } + private static ZimTrackingException retryable(String code, String message, Throwable cause) { + return new ZimTrackingException("RETRYABLE_FAILURE", code, message, cause); + } + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + } + private static String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); } + + private record AccessToken(String value, Instant expiresAt) { + boolean usableAt(Instant now) { return expiresAt.minus(TOKEN_REFRESH_SKEW).isAfter(now); } + } + public record TrackingResponse(TracingPayload payload, String rawBody) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TracingPayload(FinalEta finalETA, List routeDetails, BkBlDetails bkBlDetails, + String bookingReference, String blReference) { + public List safeRoutes() { return routeDetails == null ? List.of() : List.copyOf(routeDetails); } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record FinalEta(String etaValue, String etaValueDate, String podInd) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record RouteDetail(String sailingDateTz, String sailingDateDT, String arrivalDateTz, + String arrivalDateDT, String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + @JsonIgnoreProperties(ignoreUnknown = true) + public record BkBlDetails(List consContainerList) { + public List safeContainers() { + return consContainerList == null ? List.of() : List.copyOf(consContainerList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerDetail(String containerNumber, List containerEventsList) { + public List safeEvents() { + return containerEventsList == null ? List.of() : List.copyOf(containerEventsList); + } + } + @JsonIgnoreProperties(ignoreUnknown = true) + public record ContainerEvent(String activityDateTz, String activityDate, String eventName, + String portCode, String port, String country, + String vesselName, String voyage, String leg) { } + public static final class ZimTrackingException extends RuntimeException { + private final String outcome; private final String code; + public ZimTrackingException(String outcome, String code, String message) { + super(message); this.outcome = outcome; this.code = code; + } + public ZimTrackingException(String outcome, String code, String message, Throwable cause) { + super(message, cause); this.outcome = outcome; this.code = code; + } + public String outcome() { return outcome; } + public String code() { return code; } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java index f02110ab0..316bbf195 100644 --- a/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java +++ b/integration-service/src/main/java/com/baiyue/integration/service/CarrierScheduleProviderService.java @@ -4,6 +4,7 @@ import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.domain.CarrierScheduleStandardNode; +import com.baiyue.integration.domain.CarrierScheduleExceptionCode; import org.springframework.stereotype.Service; import java.util.List; @@ -24,7 +25,8 @@ public class CarrierScheduleProviderService { .orElseThrow(() -> new IllegalArgumentException( "没有支持该船司的轨迹供应商适配器:" + request.carrierCode())) .query(request); - if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) { + if (result == null || result.observations() == null || result.exceptions() == null + || !OUTCOMES.contains(result.outcome())) { throw new IllegalStateException("轨迹供应商返回空结果"); } if (result.observations().stream().anyMatch(value -> value == null @@ -34,6 +36,14 @@ public class CarrierScheduleProviderService { || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { throw new IllegalStateException("轨迹供应商返回了无效标准节点"); } + if (result.exceptions().stream().anyMatch(value -> value == null + || !CarrierScheduleExceptionCode.supports(value.exceptionCode()) + || value.providerEventId() == null || value.providerEventId().isBlank() + || value.providerStatus() == null || value.providerStatus().isBlank() + || value.observedAt() == null || value.rawPayloadHash() == null + || !value.rawPayloadHash().matches("[0-9a-f]{64}"))) { + throw new IllegalStateException("轨迹供应商返回了无效异常事实"); + } return result; } } diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index e5508b64f..cca1f3168 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -58,6 +58,42 @@ baiyue: aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} region: ${AMAZON_SP_API_REGION:} + carrier-schedule: + matson: + enabled: ${MATSON_TRACKING_ENABLED:false} + page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} + query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} + connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} + hede: + enabled: ${HEDE_TRACKING_ENABLED:false} + query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} + connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} + sml: + enabled: ${SML_TRACKING_ENABLED:false} + query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} + connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} + zim: + enabled: ${ZIM_TRACKING_ENABLED:false} + authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} + tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} + client-id: ${ZIM_CLIENT_ID:} + client-secret: ${ZIM_CLIENT_SECRET:} + subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} + connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} + max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} + evergreen: + enabled: ${EMC_TRACKING_ENABLED:false} + query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} + connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} + request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} + min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} management: endpoints: diff --git a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml index 54a27df9d..b376f8cea 100644 --- a/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml +++ b/integration-service/src/main/resources/openapi/carrier-schedule-provider-v1.yaml @@ -30,10 +30,11 @@ components: trackingNo: {type: string, minLength: 1, maxLength: 128} StandardNode: type: string - enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY] + enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, LOADED_ON_VESSEL, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED_DESTINATION_PORT, DISCHARGED_FROM_VESSEL, PICKUP_READY, PICKED_UP, EMPTY_RETURNED] + ExceptionCode: {type: string, enum: [CUSTOMS_HOLD, TERMINAL_HOLD, DOCUMENT_HOLD, UNKNOWN_HOLD]} QueryResult: type: object - required: [providerCode, outcome, observations] + required: [providerCode, outcome, observations, exceptions] properties: providerCode: {type: string} outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]} @@ -53,3 +54,18 @@ components: locationName: {type: string, nullable: true} sourceReference: {type: string, nullable: true} rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} + exceptions: + type: array + items: + type: object + required: [providerEventId, providerStatus, exceptionCode, active, observedAt, rawPayloadHash] + properties: + providerEventId: {type: string, maxLength: 128} + providerStatus: {type: string, maxLength: 128} + exceptionCode: {$ref: '#/components/schemas/ExceptionCode'} + active: {type: boolean} + observedAt: {type: string, format: date-time} + locationCode: {type: string, nullable: true} + locationName: {type: string, nullable: true} + sourceReference: {type: string, nullable: true} + rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..2a09fb252 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class EvergreenCarrierScheduleProviderAdapterTest { + private static final String CONTAINER_HTML = """ +
other
+ + + + + + + + +
Method地点箱号货柜动态日期柜型VGM船名航次
CYNINGBO, CHINA (CN) EGSU9319340 Loaded (FCL) on vesselAUG-07-202640HQEVER LOVELY 1232-067E
CYNINGBO, CHINA (CN)EITU1262610Loaded (FCL) on vesselAUG-06-202640HQEVER LOVELY 1232-067E
+ """; + private static final String BILL_HTML = """ +
预计装船日AUG-07-2026
+ """; + + @Test + void mapsTheExactContainerRowAndPreservesDayPrecisionWithoutUsingObservedAtAsActual() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, BILL_HTML, "0000", + CONTAINER_HTML + BILL_HTML); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 15, 30); + + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("egsu9319340", response, queriedAt); + + assertThat(values).hasSize(2); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement().satisfies(value -> { + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0)); + assertThat(value.actualAt()).isNotEqualTo(queriedAt); + assertThat(value.locationCode()).isEqualTo("CN"); + assertThat(value.locationName()).isEqualTo("NINGBO, CHINA (CN)"); + assertThat(value.sourceReference()).contains("DATE:AUG-07-2026", "PRECISION:DAY", "ZONE:Asia/Shanghai") + .doesNotContain("000000000000"); + }); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement() + .satisfies(value -> assertThat(value.estimatedAt()).isEqualTo(LocalDateTime.of(2026, 8, 7, 0, 0))); + } + + @Test + void secondConfirmedFixtureRowUsesItsOwnDateAndUnknownStatusesStayUnmapped() { + var response = new EvergreenTrackingClient.TrackingResponse(CONTAINER_HTML, "
", + "0000", CONTAINER_HTML); + var values = new EvergreenCarrierScheduleProviderAdapter(mock(EvergreenTrackingClient.class)) + .observations("EITU1262610", response, LocalDateTime.of(2026, 8, 21, 15, 30)); + + assertThat(values).singleElement().satisfies(value -> { + assertThat(value.actualAt()).isEqualTo(LocalDateTime.of(2026, 8, 6, 0, 0)); + assertThat(value.standardNode()).isEqualTo("LOADED_ON_VESSEL"); + }); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Loaded (FCL) on vessel")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(EvergreenCarrierScheduleProviderAdapter.standardNode("Discharged from vessel")).isNull(); + } + + @Test + void missingStableHeaderOrExactContainerRowIsNotParsed() { + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow( + "
箱号日期
EGSU9319340AUG-07-2026
", + "EGSU9319340")).isNull(); + assertThat(EvergreenCarrierScheduleProviderAdapter.trackingRow(CONTAINER_HTML, "OTHER0000000")).isNull(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java new file mode 100644 index 000000000..d84b1d269 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/EvergreenTrackingClientTest.java @@ -0,0 +1,91 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EvergreenTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesContainerThenRelatedBillPostsWithoutExposingTheFullBill() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = body(exchange); forms.add(form); + String html = form.startsWith("TYPE=CNTR") + ? "
" + : "
预计装船日AUG-07-2026
"; + respond(exchange, 200, html); + }); + server.start(); + + var result = client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340")); + + assertThat(forms).containsExactly("TYPE=CNTR&CNTR=EGSU9319340", "TYPE=BL&BL=000000000000"); + assertThat(result.billSuffix()).isEqualTo("0000"); + assertThat(result.toString()).doesNotContain("000000000000"); + } + + @Test + void extractsTheRelatedBillFromHiddenInputOrDispInfoLink() { + assertThat(EvergreenTrackingClient.relatedBill( + "" )).isEqualTo("000000000000"); + assertThat(EvergreenTrackingClient.relatedBill( + "detail" )).isEqualTo("000000000000"); + } + + @Test + void nonHtmlAndMissingRelatedBillBecomeTraceableManualFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "{\"unexpected\":true}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_CONTAINER_NON_HTML")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "
")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("EMC", "CONTAINER_NO", "EGSU9319340"))) + .isInstanceOfSatisfying(EvergreenTrackingClient.EvergreenTrackingException.class, + value -> assertThat(value.code()).isEqualTo("EMC_RELATED_BL_MISSING")); + } + + private EvergreenTrackingClient client() { + var evergreen = new IntegrationProperties.CarrierSchedule.Evergreen(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + EvergreenTrackingClient client = new EvergreenTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, null, evergreen))); + client.initialize(); return client; + } + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..f5cdfd456 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,95 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.HedeTrackingClient.TrackingResponse; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HedeCarrierScheduleProviderAdapterTest { + @Test + void parsesConfirmedMainTableColumnsAndDoesNotTurnEtaIntoAnActualNode() throws Exception { + String html = row("XYLU8251457", "2026-08-20 00:00", "2026-09-09 00:00", + "", "", "", "", "", ""); + + var rows = HedeCarrierScheduleProviderAdapter.rows(html); + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + assertThat(rows).singleElement().satisfies(value -> { + assertThat(value.containerNo()).isEqualTo("XYLU8251457"); + assertThat(value.vessel()).isEqualTo("POSEN"); + assertThat(value.voyage()).isEqualTo("2607W"); + assertThat(value.estimatedDeparture()).isEqualTo("2026-08-20 00:00"); + assertThat(value.estimatedArrival()).isEqualTo("2026-09-09 00:00"); + }); + assertThat(adapter.observations("XYLU8251457", html)).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT"); + assertThat(adapter.observations("XYLU8251457", html)).allSatisfy(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + } + + @Test + void mapsOnlyActualHedeMilestonesForTheRequestedContainer() { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", ""); + HedeTrackingClient client = mock(HedeTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673"))) + .thenReturn(new TrackingResponse(html)); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "CONTAINER_NO", "FFAU4879673")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", "AT_ORIGIN_PORT", + "LOADED_ON_VESSEL"); + assertThat(result.observations()).allSatisfy(value -> { + assertThat(value.providerEventId()).hasSize(64); + assertThat(value.rawPayloadHash()).hasSize(64); + assertThat(value.sourceReference()).contains("FFAU4879673").contains("POSEN/2607W"); + }); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + } + + @Test + void actualEmptyReturnIsMappedPrecisely() throws Exception { + String html = row("FFAU4879673", "2026-08-05 04:24", "2026-08-22 00:00", + "2026-07-30 21:31", "2026-08-01 15:55", "2026-08-04 19:45", "", "", + "2026-08-28 08:00"); + HedeTrackingClient client = mock(HedeTrackingClient.class); + var result = new HedeCarrierScheduleProviderAdapter(client).observations("FFAU4879673", html); + assertThat(result).filteredOn(value -> "EMPTY_RETURNED".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.actualAt()).isNotNull(); + assertThat(value.estimatedAt()).isNull(); + }); + } + + @Test + void refusesAmbiguousBillLevelAutomation() { + HedeTrackingClient client = mock(HedeTrackingClient.class); + HedeCarrierScheduleProviderAdapter adapter = new HedeCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("HEDE", "BILL_OF_LADING", "HDE123")); + + assertThat(result.outcome()).isEqualTo("PERMANENT_FAILURE"); + assertThat(result.errorCode()).isEqualTo("HEDE_CONTAINER_REQUIRED"); + } + + private static String row(String container, String etd, String eta, String emptyOut, String fullIn, + String loaded, String discharged, String fullOut, String emptyReturned) { + return "" + + td("***") + td(container) + td("POSEN") + td("2607W") + td(etd) + td(eta) + + td(emptyOut) + td(fullIn) + td(loaded) + td(discharged) + td(fullOut) + td(emptyReturned) + + td("查看") + "
"; + } + + private static String td(String value) { return "" + value + ""; } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java new file mode 100644 index 000000000..b7b229a55 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/HedeTrackingClientTest.java @@ -0,0 +1,59 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class HedeTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void postsTheConfirmedHedeFormAsUtf8() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/getVBilldynamic", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + respond(exchange, 200, "" + + "
***XYLU8251457
"); + }); + server.start(); + + var hede = new IntegrationProperties.CarrierSchedule.Hede(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/getVBilldynamic"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + HedeTrackingClient client = new HedeTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, hede, null, null, null))); + client.initialize(); + + var response = client.query(new QueryRequest("HEDE", "CONTAINER_NO", "XYLU8251457")); + + assertThat(response.html()).contains("XYLU8251457"); + assertThat(postedBody.get()).isEqualTo( + "billno=&cntr=XYLU8251457&selecttype=1&resultlist=1¶mtype=0"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..c352c07d2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,109 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.baiyue.integration.provider.MatsonTrackingClient.TrackingResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MatsonCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void realMatu4646247FixtureProducesTwoActualsAndEtaOnly() throws Exception { + String raw = """ + {"bookingNumber":"MATSON-REAL","containerNumber":"MATU464624","checkDigit":"7", + "originPort":"LONG BEACH","destPort":"HONOLULU","originCode":"LAX","destCode":"HIL", + "loadPort":"LONG BEACH","dischargePort":"HONOLULU","vesselETA":"08/25/26","events":[ + {"date":%d,"location":"LONG BEACH","status":"INGATE"}, + {"date":%d,"location":"LONG BEACH","status":"LOAD TO VESSEL"}]} + """.formatted(Instant.parse("2026-08-18T19:15:00Z").toEpochMilli(), + Instant.parse("2026-08-18T22:12:00Z").toEpochMilli()); + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4646247")); + + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null).hasSize(2); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).singleElement(); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.exceptions()).isEmpty(); + } + + @Test + void conservativelyMapsKnownMatsonEventsWithoutTreatingLoadAsDeparture() throws Exception { + String raw = """ + {"bookingNumber":"9974749","containerNumber":"MATU459895","checkDigit":"6", + "originPort":"NINGBO","destPort":"LONG BEACH","originCode":"NGB","destCode":"LAX", + "vesselETA":"08/24/26", + "loadPort":"NINGBO","dischargePort":"LONG BEACH","events":[ + {"date":1787160480000,"location":"","status":"CONTAINER ON HOLD - Customs Clearance"}, + {"date":1787033640000,"location":"NINGBO","status":"LOAD TO VESSEL"}, + {"date":1786940100000,"location":"NINGBO","status":"INGATE"}]} + """; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + MatsonCarrierScheduleProviderAdapter adapter = new MatsonCarrierScheduleProviderAdapter(client); + + var result = adapter.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "ARRIVED_DESTINATION_PORT"); + assertThat(result.observations()).filteredOn(value -> "ARRIVED_DESTINATION_PORT".equals(value.standardNode())) + .singleElement().satisfies(value -> { + assertThat(value.estimatedAt()).isNotNull(); + assertThat(value.actualAt()).isNull(); + }); + assertThat(result.exceptions()).singleElement().satisfies(value -> { + assertThat(value.exceptionCode()).isEqualTo("CUSTOMS_HOLD"); + assertThat(value.active()).isTrue(); + assertThat(value.providerStatus()).isEqualTo("CONTAINER ON HOLD - Customs Clearance"); + }); + } + + @Test + void mapsDestinationDischargeAndAvailabilityWithoutUsingEtaAsActualArrival() { + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("LOAD TO VESSEL")) + .isEqualTo("LOADED_ON_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("DISCHARGE FROM VESSEL")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("AVAILABLE FOR PICKUP")) + .isEqualTo("PICKUP_READY"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("OUTGATE")).isEqualTo("PICKED_UP"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("EMPTY RETURNED")) + .isEqualTo("EMPTY_RETURNED"); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("PICKUP APPOINTMENT")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.standardNode("CONTAINER ON HOLD - Customs Clearance")) + .isNull(); + assertThat(MatsonCarrierScheduleProviderAdapter.exceptionCode( + "CONTAINER ON HOLD - Customs Clearance")).isEqualTo("CUSTOMS_HOLD"); + } + + @Test + void emptyBusinessObjectReturnsNoFacts() throws Exception { + String raw = "{}"; + MatsonTrackingClient client = mock(MatsonTrackingClient.class); + when(client.configured()).thenReturn(true); + when(client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086"))) + .thenReturn(new TrackingResponse(objectMapper.readTree(raw), raw)); + var result = new MatsonCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU5164086")); + assertThat(result.outcome()).isEqualTo("NO_CHANGE"); + assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java new file mode 100644 index 000000000..e0bd01d1d --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/MatsonTrackingClientTest.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatsonTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { + if (server != null) server.stop(0); + } + + @Test + void obtainsDynamicTtrAndCookieBeforePostingAjaxForm() throws Exception { + AtomicReference postedBody = new AtomicReference<>(); + AtomicReference postedCookie = new AtomicReference<>(); + AtomicReference requestedWith = new AtomicReference<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/page", exchange -> { + exchange.getResponseHeaders().add("Set-Cookie", "matson-session=abc123; Path=/"); + respond(exchange, 200, ""); + }); + server.createContext("/query", exchange -> { + postedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + postedCookie.set(exchange.getRequestHeaders().getFirst("Cookie")); + requestedWith.set(exchange.getRequestHeaders().getFirst("X-Requested-With")); + respond(exchange, 200, "{\"containerNumber\":\"MATU459895\",\"checkDigit\":\"6\",\"events\":[]}"); + }); + server.start(); + + int port = server.getAddress().getPort(); + var matson = new IntegrationProperties.CarrierSchedule.Matson(true, + URI.create("http://127.0.0.1:" + port + "/page"), + URI.create("http://127.0.0.1:" + port + "/query"), + Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO); + MatsonTrackingClient client = new MatsonTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(matson, null, null, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + + var result = client.query(new QueryRequest("MATSON", "CONTAINER_NO", "MATU4598956")); + + assertThat(result.payload().path("containerNumber").asText()).isEqualTo("MATU459895"); + assertThat(postedBody.get()).contains("container_trackingnumber=MATU4598956") + .contains("trackingVal=tckconta").contains("ttr=dynamic123"); + assertThat(postedCookie.get()).contains("matson-session=abc123"); + assertThat(requestedWith.get()).isEqualTo("XMLHttpRequest"); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..517556227 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,71 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SmlCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void sevenSampleBoundaryUsesActTypeAndLeavesFitmdoUnmapped() throws Exception { + String timeline = """ + {"TRANS_RESULT_KEY":"S","count":9,"events":[ + {"actCd":"FOTMAD","actTpCd":"A","eventDt":"2026-08-01 15:55"}, + {"actCd":"FLVMLO","actTpCd":"A","eventDt":"2026-08-04 19:45"}, + {"actCd":"FLVMDO","actTpCd":"A","eventDt":"2026-08-07 17:00"}, + {"actCd":"FUVMAD","actTpCd":"E","eventDt":"2026-08-23 14:00"}, + {"actCd":"FUVMBD","actTpCd":"E","eventDt":"2026-08-23 16:00"}, + {"actCd":"FUVMUD","actTpCd":"E","eventDt":"2026-08-24 08:00"}, + {"actCd":"FITMDO","actTpCd":"E","eventDt":"2026-08-25 08:00"}, + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MOTYDO","actTpCd":"A","eventDt":"2026-07-28 08:00"}]} + """; + SmlTrackingClient client = mock(SmlTrackingClient.class); + when(client.configured()).thenReturn(true); + var response = new SmlTrackingClient.TrackingResponse("SMLBKG", "COP1", + objectMapper.readTree(timeline), objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + when(client.query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))).thenReturn(response); + + var result = new SmlCarrierScheduleProviderAdapter(client) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.observations()).hasSize(7); + assertThat(result.observations()).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(result.observations()).filteredOn(value -> value.estimatedAt() != null).hasSize(4); + assertThat(result.observations()).extracting(value -> value.observedAt()) + .containsOnly(result.observations().get(0).observedAt()); + assertThat(result.observations()).extracting(value -> value.providerNodeCode()) + .doesNotContain("FITMDO", "MOTYDO"); + } + + @Test + void onlyActualMityadCanBecomeActualEmptyReturn() throws Exception { + String timeline = """ + {"events":[ + {"actCd":"MITYAD","actTpCd":"E","eventDt":"2026-09-01 08:00"}, + {"actCd":"MITYAD","actTpCd":"A","eventDt":"2026-09-02 09:00"}]} + """; + var response = new SmlTrackingClient.TrackingResponse("BKG", "COP", objectMapper.readTree(timeline), + objectMapper.createObjectNode(), objectMapper.createObjectNode(), timeline); + var values = new SmlCarrierScheduleProviderAdapter(mock(SmlTrackingClient.class)) + .observations("SMCU7019890", response); + assertThat(values).filteredOn(value -> value.actualAt() != null).singleElement() + .satisfies(value -> assertThat(value.standardNode()).isEqualTo("EMPTY_RETURNED")); + assertThat(values).filteredOn(value -> value.estimatedAt() != null).singleElement(); + } + + @Test + void ambiguousFitmdoAndCargoEmptyReleaseStayUnmapped() { + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FITMDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("MOTYDO")).isNull(); + assertThat(SmlCarrierScheduleProviderAdapter.standardNode("FUVMUD")) + .isEqualTo("DISCHARGED_FROM_VESSEL"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java new file mode 100644 index 000000000..6ed6966e2 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/SmlTrackingClientTest.java @@ -0,0 +1,147 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SmlTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void executesTheConfirmed122125124126Chain() throws Exception { + List forms = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + String form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + forms.add(form); + String body = form.contains("f_cmd=122") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"data\":[{\"bkgNo\":\"BKG1\",\"copNo\":\"COP1\"}]}" + : form.contains("f_cmd=125") + ? "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1,\"events\":[{\"actCd\":\"FOTMAD\",\"actTpCd\":\"A\",\"eventDt\":\"2026-08-01 15:55\"}]}" + : "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}"; + respond(exchange, 200, body); + }); + server.start(); + + var result = new SmlCarrierScheduleProviderAdapter(client()) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890")); + + assertThat(result.outcome()).isEqualTo("SUCCESS"); + assertThat(result.observations()).singleElement() + .satisfies(value -> assertThat(value.sourceReference()).isEqualTo("SML:BKG1:COP1")); + assertThat(forms).hasSize(4); + assertThat(forms.get(0)).contains("f_cmd=122").contains("cntr_no=SMCU7019890"); + assertThat(forms.get(1)).contains("f_cmd=125").contains("bkg_no=BKG1").contains("cop_no=COP1"); + assertThat(forms.get(2)).contains("f_cmd=124").contains("bkg_no=BKG1"); + assertThat(forms.get(3)).contains("f_cmd=126").contains("cntr_no=SMCU7019890"); + } + + @Test + void nonJsonAndMissingBusinessKeysFailClosed() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, "schema changed")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).contains("NON_JSON")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_KEYS_MISSING")); + } + + @Test + void providerFailureAndZeroCountAreTraceableNotFoundResults() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"F\",\"count\":1}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_BUSINESS_FAILED")); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 200, + "{\"TRANS_RESULT_KEY\":\"S\",\"count\":0}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, + value -> assertThat(value.code()).isEqualTo("SML_NOT_FOUND")); + } + + @Test + void nonSuccessHttpAndTimeoutRemainTraceableRetryableFailures() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> respond(exchange, 503, "{}")); + server.start(); + assertThatThrownBy(() -> client().query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_HTTP_503"); + assertThat(value.retryable()).isTrue(); + }); + + server.stop(0); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/track", exchange -> { + try { Thread.sleep(200); } + catch (InterruptedException exception) { Thread.currentThread().interrupt(); } + respond(exchange, 200, "{}"); + }); + server.start(); + assertThatThrownBy(() -> client(Duration.ofMillis(50)) + .query(new QueryRequest("SML", "CONTAINER_NO", "SMCU7019890"))) + .isInstanceOfSatisfying(SmlTrackingClient.SmlTrackingException.class, value -> { + assertThat(value.code()).isEqualTo("SML_LOOKUP_TIMEOUT"); + assertThat(value.retryable()).isTrue(); + }); + } + + private SmlTrackingClient client() { + return client(Duration.ofSeconds(2)); + } + + private SmlTrackingClient client(Duration requestTimeout) { + var sml = new IntegrationProperties.CarrierSchedule.Sml(true, + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/track"), + Duration.ofSeconds(2), requestTimeout, Duration.ZERO); + SmlTrackingClient client = new SmlTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, sml, null, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); + client.initialize(); + return client; + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java new file mode 100644 index 000000000..9bb1064a6 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimCarrierScheduleProviderAdapterTest.java @@ -0,0 +1,106 @@ +package com.baiyue.integration.provider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ZimCarrierScheduleProviderAdapterTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void v2FixtureSelectsOnlyTheExactContainerAndSeparatesActualFromPlans() throws Exception { + String fixture = """ + {"bookingReference":" BK-STRING-001 ","blReference":" BL-STRING-001 ", + "finalETA":{"etaValue":"2023-09-03T08:00:00","etaValueDate":"2023-09-03","podInd":"ETA"}, + "routeDetails":[ + {"sailingDateTz":"2023-08-10T12:00:00","arrivalDateTz":"2023-08-17T06:00:00","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"V1","voyage":"001E","leg":"1"}, + {"sailingDateDT":"2023-08-18T10:00:00","arrivalDateDT":"2023-09-02T04:00:00","portCode":"USNYC","port":"New York","country":"US","vesselName":"V2","voyage":"002E","leg":"2"}, + {"portCode":"USNYC","port":"New York","country":"US"}], + "bkBlDetails":{"consContainerList":[ + {"containerNumber":"SIBLING000001 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Export Gate-In to Port of Loading","portCode":"CNNGB"}]}, + {"containerNumber":" zcsu7881970 ","containerEventsList":[ + {"activityDateTz":"2023-08-01T01:00:00","eventName":"Empty container dispatched from Port of Loading to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2023-08-10T02:00:00","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2023-08-18T11:00:00","eventName":"Vessel departure from first Transshipment Port to next Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-19T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDate":"2023-08-20T12:00:00","eventName":"Container was loaded at Transshipment Port to Transshipment Port","portCode":"SGSIN","port":"Singapore","country":"SG","vesselName":"V2","voyage":"002E","leg":"2"}, + {"activityDateTz":"2023-08-21T12:00:00","eventName":"Unknown Future Event","portCode":"SGSIN"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + LocalDateTime queriedAt = LocalDateTime.of(2026, 8, 21, 12, 0); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("ZCSU7881970", response, queriedAt); + + assertThat(values).hasSize(7); + assertThat(values).extracting(value -> value.observedAt()).containsOnly(queriedAt); + assertThat(values).filteredOn(value -> value.actualAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "IN_TRANSIT", "LOADED_ON_VESSEL", "LOADED_ON_VESSEL"); + assertThat(values).filteredOn(value -> value.estimatedAt() != null) + .extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("DEPARTED_ORIGIN_PORT", "ARRIVED_DESTINATION_PORT", + "ARRIVED_DESTINATION_PORT"); + assertThat(values).noneMatch(value -> LocalDateTime.of(2023, 8, 1, 1, 0).equals(value.actualAt())); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("EMPTY CONTAINER DISPATCHED FROM PORT OF LOADING TO CUSTOMER", "UNKNOWN FUTURE EVENT"); + assertThat(values).allSatisfy(value -> assertThat(value.sourceReference()) + .isEqualTo("ZIM:CONTAINER:ZCSU7881970|BOOKING:BK-STRING-001|BL:BL-STRING-001")); + } + + @Test + void eventNameMappingIsAnExactExpandableWhitelist() { + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Export Gate-In to Port of Loading")).isEqualTo("AT_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Transshipment Port to Transshipment Port")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from first Transshipment Port to next Transshipment Port")).isEqualTo("IN_TRANSIT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container loaded at Port of Loading")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Container was loaded at Port of Loading to Port of Discharge")).isEqualTo("LOADED_ON_VESSEL"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Vessel departure from Port of Loading to Port of Discharge")).isEqualTo("DEPARTED_ORIGIN_PORT"); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode( + "Empty container dispatched from Port of Loading to Customer")).isNull(); + assertThat(ZimCarrierScheduleProviderAdapter.standardNode("Unknown Future Event")).isNull(); + } + + @Test + void liveBkBlOnlyFixtureToleratesMissingPlansAndDoesNotAdvanceCarrierRelease() throws Exception { + String fixture = """ + {"bkBlDetails":{"consContainerList":[ + {"containerNumber":" ZCSU7205614 ","containerEventsList":[ + {"activityDateTz":"2026-08-01T01:00:00Z","eventName":"Empty container dispatched from inland point to Customer","portCode":"CNNGB"}, + {"activityDateTz":"2026-08-03T02:00:00Z","eventName":"Export gate-in from Customer to Port of Loading","portCode":"CNNGB","port":"Ningbo","country":"CN"}, + {"activityDateTz":"2026-08-05T03:00:00Z","eventName":"Container was loaded at Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-06T04:00:00Z","eventName":"Vessel departure from Port of Loading to Port of Discharge","portCode":"CNNGB","port":"Ningbo","country":"CN","vesselName":"LIVE VESSEL","voyage":"001E","leg":"1"}, + {"activityDateTz":"2026-08-20T05:00:00Z","eventName":"Carrier Release","portCode":"USLAX","port":"Los Angeles","country":"US"}]}, + {"containerNumber":"ZCSU7960370","containerEventsList":[ + {"activityDateTz":"2026-08-07T00:00:00Z","eventName":"Export gate-in from Customer to Port of Loading"}]}]}} + """; + var payload = objectMapper.readValue(fixture, ZimTrackingClient.TracingPayload.class); + var response = new ZimTrackingClient.TrackingResponse(payload, fixture); + + var values = new ZimCarrierScheduleProviderAdapter(mock(ZimTrackingClient.class)) + .observations("zcsu7205614", response, LocalDateTime.of(2026, 8, 21, 12, 0)); + + assertThat(payload.routeDetails()).isNull(); + assertThat(payload.finalETA()).isNull(); + assertThat(values).hasSize(3).allSatisfy(value -> assertThat(value.actualAt()).isNotNull()); + assertThat(values).extracting(value -> value.standardNode()) + .containsExactlyInAnyOrder("AT_ORIGIN_PORT", "LOADED_ON_VESSEL", "DEPARTED_ORIGIN_PORT"); + assertThat(values).extracting(value -> value.standardNode()).doesNotContain("PICKUP_READY"); + assertThat(values).extracting(value -> value.providerNodeCode()) + .doesNotContain("CARRIER RELEASE", "EMPTY CONTAINER DISPATCHED FROM INLAND POINT TO CUSTOMER"); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java new file mode 100644 index 000000000..01e0f9de7 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/ZimTrackingClientTest.java @@ -0,0 +1,152 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ZimTrackingClientTest { + private HttpServer server; + + @AfterEach + void stopServer() { if (server != null) server.stop(0); } + + @Test + void usesOAuthTokenCacheAndContainerOnlyTracingPath() throws Exception { + Credentials credentials = Credentials.random(); + String accessToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicReference authForm = new AtomicReference<>(); + AtomicReference tracingPath = new AtomicReference<>(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + authCalls.incrementAndGet(); authForm.set(body(exchange)); + respond(exchange, 200, tokenBody(accessToken)); + }); + server.createContext("/tracing/v2", exchange -> { + tracingPath.set(exchange.getRequestURI().getPath()); + assertThat(exchange.getRequestHeaders().getFirst("Authorization")).isEqualTo("bearer " + accessToken); + assertThat(exchange.getRequestHeaders().getFirst("Ocp-Apim-Subscription-Key")) + .isEqualTo(credentials.subscriptionKey()); + respond(exchange, 200, successBody()); + }); + server.start(); + ZimTrackingClient client = client(credentials); + + client.query(query()); client.query(query()); + + assertThat(authCalls).hasValue(1); + assertThat(authForm.get()).contains("grant_type=client_credentials", "scope=tracing") + .contains("client_id=", "client_secret="); + assertThat(tracingPath.get()).isEqualTo("/tracing/v2/ZCSU7881970"); + } + + @Test + void unauthorizedRefreshesTokenAndRetriesTracingExactlyOnce() throws Exception { + Credentials credentials = Credentials.random(); + String firstToken = UUID.randomUUID().toString(); + String secondToken = UUID.randomUUID().toString(); + AtomicInteger authCalls = new AtomicInteger(); + AtomicInteger tracingCalls = new AtomicInteger(); + server = server(); + server.createContext("/authorize/v1", exchange -> { + int call = authCalls.incrementAndGet(); respond(exchange, 200, tokenBody(call == 1 ? firstToken : secondToken)); + }); + server.createContext("/tracing/v2", exchange -> { + int call = tracingCalls.incrementAndGet(); + respond(exchange, call == 1 ? 401 : 200, call == 1 ? "{}" : successBody()); + }); + server.start(); + + client(credentials).query(query()); + + assertThat(authCalls).hasValue(2); + assertThat(tracingCalls).hasValue(2); + } + + @Test + void forbiddenIsManualAuthorizationFailureAndDoesNotEchoSecrets() throws Exception { + Credentials credentials = Credentials.random(); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 403, "forbidden")); + server.start(); + + assertThatThrownBy(() -> client(credentials).query(query())) + .isInstanceOfSatisfying(ZimTrackingClient.ZimTrackingException.class, value -> { + assertThat(value.outcome()).isEqualTo("MANUAL_REQUIRED"); + assertThat(value.code()).isEqualTo("ZIM_HTTP_403"); + assertThat(value.getMessage()).doesNotContain(credentials.clientSecret()) + .doesNotContain(credentials.subscriptionKey()).doesNotContain("ZIM_NOT_FOUND"); + }); + } + + @Test + void emptyOrMissingContainerListRequiresManualHandling() throws Exception { + Credentials credentials = Credentials.random(); + AtomicReference businessBody = new AtomicReference<>( + "{\"bkBlDetails\":{\"consContainerList\":[]}}"); + server = server(); + server.createContext("/authorize/v1", exchange -> respond(exchange, 200, tokenBody(UUID.randomUUID().toString()))); + server.createContext("/tracing/v2", exchange -> respond(exchange, 200, businessBody.get())); + server.start(); + ZimTrackingClient client = client(credentials); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, value -> assertThat(value.code()).isEqualTo("ZIM_NOT_FOUND")); + + businessBody.set("{}"); + assertThatThrownBy(() -> client.query(query())).isInstanceOfSatisfying( + ZimTrackingClient.ZimTrackingException.class, + value -> assertThat(value.code()).isEqualTo("ZIM_EMPTY_RESULT")); + } + + private HttpServer server() throws IOException { return HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); } + private QueryRequest query() { return new QueryRequest("ZIM", "CONTAINER_NO", "ZCSU7881970"); } + + private ZimTrackingClient client(Credentials credentials) { + String root = "http://127.0.0.1:" + server.getAddress().getPort(); + var zim = new IntegrationProperties.CarrierSchedule.Zim(true, URI.create(root + "/authorize/v1"), + URI.create(root + "/tracing/v2"), credentials.clientId(), credentials.clientSecret(), + credentials.subscriptionKey(), Duration.ofSeconds(2), Duration.ofSeconds(2), 10); + ZimTrackingClient client = new ZimTrackingClient(); + ReflectionTestUtils.setField(client, "properties", new IntegrationProperties(null, null, + new IntegrationProperties.CarrierSchedule(null, null, null, zim, null))); + ReflectionTestUtils.setField(client, "objectMapper", new ObjectMapper()); client.initialize(); return client; + } + + private static String body(HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + private static String tokenBody(String token) { + return "{\"access_token\":\"" + token + "\",\"expires_in\":3600}"; + } + private static String successBody() { + return "{\"bkBlDetails\":{\"consContainerList\":[{\"containerNumber\":\" ZCSU7881970 \"}]}}"; + } + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + } + private record Credentials(String clientId, String clientSecret, String subscriptionKey) { + static Credentials random() { + return new Credentials(UUID.randomUUID().toString(), UUID.randomUUID().toString(), UUID.randomUUID().toString()); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java index e5ec3bc04..e14c800f3 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/CarrierScheduleProviderServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.integration.service; import com.baiyue.integration.dto.CarrierScheduleDtos.Observation; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest; import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult; +import com.baiyue.integration.dto.CarrierScheduleDtos.ExceptionObservation; import com.baiyue.integration.provider.CarrierScheduleProviderAdapter; import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter; import org.junit.jupiter.api.Test; @@ -21,6 +22,7 @@ class CarrierScheduleProviderServiceTest { assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED"); assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED"); assertThat(result.observations()).isEmpty(); + assertThat(result.exceptions()).isEmpty(); } @Test @@ -30,7 +32,7 @@ class CarrierScheduleProviderServiceTest { @Override public QueryResult query(QueryRequest request) { return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN", LocalDateTime.now(), null, null, null, null, null, - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), List.of(), null, null, LocalDateTime.now()); } }; @@ -38,4 +40,20 @@ class CarrierScheduleProviderServiceTest { assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) .hasMessageContaining("无效标准节点"); } + + @Test + void rejectsProviderHoldsOutsideTheFourFrozenCodes() { + CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() { + @Override public boolean supports(String carrierCode) { return true; } + @Override public QueryResult query(QueryRequest request) { + return new QueryResult("TEST", "SUCCESS", List.of(), List.of(new ExceptionObservation( + "hold-1", "RAW HOLD", "OTHER_HOLD", true, LocalDateTime.now(), null, null, null, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null, + LocalDateTime.now()); + } + }; + var service = new CarrierScheduleProviderService(List.of(invalid)); + assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"))) + .hasMessageContaining("无效异常事实"); + } } diff --git a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java index baa90465c..bade2d63b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/service/ocr/WeightOcrBatchServiceTest.java @@ -80,7 +80,7 @@ class WeightOcrBatchServiceTest { URI.create("https://example.com/meter"), Duration.ofSeconds(1), Duration.ofSeconds(1), inputRoot, outputRoot, DataSize.ofMegabytes(3), 40_000_000L, 50, Duration.ZERO, 0); - return new IntegrationProperties(baidu, null); + return new IntegrationProperties(baidu, null, null); } @Configuration diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 3195018da..d040f1727 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -87,11 +87,11 @@ async function createManualSchedule(context, suffix, oceanBookingId, customerId) } export async function run(context) { - context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询', - 'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定', + context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官方API自动查询', + 'ZIM Cargo Tracking API 未配置订阅凭据或来源授权时返回MANUAL_REQUIRED;不记录或回显订阅键', 'external-third-party') - context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询', - 'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功', + context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官方API自动查询', + 'COSCO Shipping Open Platform Cargo Tracking API 待注册订阅和凭据;当前返回MANUAL_REQUIRED,不逆向网页WAF或伪造自动成功', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) @@ -100,8 +100,10 @@ export async function run(context) { const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) - && byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true - && byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true + && byCode.get('ZIM')?.accessMode === 'OFFICIAL_API' && byCode.get('ZIM')?.coverageGap === true + && byCode.get('ZIM')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' + && byCode.get('COSCO')?.accessMode === 'OFFICIAL_API' && byCode.get('COSCO')?.coverageGap === true + && byCode.get('COSCO')?.coverageGapCode === 'OFFICIAL_API_CREDENTIALS_REQUIRED' && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false), `codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS) @@ -130,29 +132,7 @@ export async function run(context) { id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) - let version = Number(created.query.query.version) - for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) { - const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: version, reason: `第${attemptNo}次官网查询` }, - { 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) }) - const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS) - context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response, - detail?.query?.query?.status === 'MANUAL_REQUIRED' - && Number(detail?.query?.query?.attemptCount) === attemptNo - && detail?.attempts?.length === attemptNo - && detail?.attempts?.at(-1)?.coverageGap === true - && detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED' - && Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason), - `status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS) - version = Number(detail?.query?.query?.version) - } - - const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: version, reason: '超过窗口上限' }, - { 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) }) - step(context, '同一3天窗口第4次查询被服务端拒绝', fourth, - fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)), - `HTTP=${fourth.http}, code=${codeOf(fourth)}`) + const version = Number(created.query.query.version) const unknownCarrier = await request(context, 'POST', BASE, { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` }, @@ -175,25 +155,17 @@ export async function run(context) { { 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) }) const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS) context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, - completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId + completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) && Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS) - const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`, - { expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' }, - { 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) }) - step(context, '成功后未到3天禁止再次查询', early, - early.http === 200 && ['A0409', '409'].includes(codeOf(early)), - `HTTP=${early.http}, code=${codeOf(early)}`) - const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS) - context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse, - detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED') + context.assert(MODULE, '平台详情不暴露调度尝试且保留业务版本历史', detailResponse, + !Object.hasOwn(detail || {}, 'attempts') && detail?.histories?.some((item) => item.action === 'CREATE') - && detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3 && detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'), - `attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) + `attemptsExposed=${Object.hasOwn(detail || {}, 'attempts')}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 729e39657..2cd67e92a 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -4,7 +4,7 @@ import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' +import { EXCEPTION_CODES, QUERY_STATUSES, STANDARD_NODES, run } from './phase4-carrier-nodes.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-carrier-nodes.mjs'), 'utf8') @@ -14,9 +14,10 @@ function contextFor(routes) { const calls = [] return { calls, - request: async (method, requestPath) => { - calls.push({ method, path: requestPath }) - assert.equal(method, 'GET') + marker: 'T-PHASE4', token: 'token', + request: async (method, requestPath, body, _token, headers = {}) => { + calls.push(method === 'POST' ? { method, path: requestPath, body, headers } : { method, path: requestPath }) + assert.ok(['GET', 'POST'].includes(method)) assert.ok(Object.hasOwn(routes, requestPath), `unexpected path: ${requestPath}`) return response(routes[requestPath]) }, @@ -35,16 +36,22 @@ const task = { id: '9007199254740993', queryNo: 'CSQ9007199254740993', customerId: '10001', oceanBookingId: '20001', containerBookingId: '30001', carrierCode: 'COSCO', trackingType: 'CONTAINER_NO', trackingNo: 'CSNU1234567', status: 'STOPPED', - latestStandardNode: 'PICKUP_READY', latestNodeObservedAt: '2026-08-20T12:00:00', + latestActualNode: 'PICKED_UP', latestActualAt: '2026-08-20T12:00:00', version: 5, lastSuccessAt: '2026-08-20T12:00:00', sourceUpdatedAt: '2026-08-20T12:00:00', - expiresAt: null, stoppedAt: '2026-08-20T12:01:00', stopReason: '目的港可提柜后停止', + expiresAt: null, stoppedAt: null, stopReason: null, } const node = { - id: '40001', providerCode: 'COSCO', providerNodeCode: 'PICKUP', standardNode: 'PICKUP_READY', - nodeSequence: 60, observedAt: '2026-08-20T12:00:00', estimatedAt: null, + id: '40001', providerCode: 'COSCO', providerEventId: 'event-40001', providerNodeCode: 'OUTGATE', standardNode: 'PICKED_UP', + nodeSequence: 90, observedAt: '2026-08-20T12:00:00', estimatedAt: null, actualAt: '2026-08-20T11:55:00', locationCode: 'USLAX', locationName: 'Los Angeles', - sourceReference: null, createdAt: '2026-08-20T12:00:01', + sourceReference: null, rawPayloadHash: 'a'.repeat(64), createdAt: '2026-08-20T12:00:01', } +const manualNode = { ...node, id: '40002', providerCode: 'MANUAL', providerEventId: 'manual-40002', + providerNodeCode: 'PICKED_UP', estimatedAt: '2026-08-20T12:00:00', actualAt: null, rawPayloadHash: 'b'.repeat(64) } +const exception = { id: '45001', providerCode: 'MATSON_PUBLIC_TRACKING', providerEventId: 'hold-1', + providerStatus: 'CONTAINER ON HOLD - Customs Clearance', exceptionCode: 'CUSTOMS_HOLD', active: true, + observedAt: '2026-08-20T12:00:00', locationCode: 'USLAX', locationName: 'Long Beach', + sourceReference: 'MATSON:9974749', rawPayloadHash: 'c'.repeat(64), createdAt: '2026-08-20T12:00:01' } const history = { id: '50001', action: 'STOP', fromVersion: 4, toVersion: 5, reason: null, operatorId: null, operatorName: '系统', createdAt: '2026-08-20T12:01:00', @@ -54,10 +61,12 @@ const carriers = [{ accessMode: 'INTERNAL_PROVIDER', automated: true, coverageGap: false, }] -test('readonly module statically forbids writes and internal execution endpoints', () => { +test('module only permits the documented manual-node write and forbids internal execution endpoints', () => { assert.match(source, /context\.request\('GET'/) assert.doesNotMatch(source, /\bfetch\s*\(/) - for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + assert.match(source, /context\.request\('POST',[\s\S]*\/manual-nodes/) + assert.match(source, /'Idempotency-Key'/) + for (const method of ['PUT', 'PATCH', 'DELETE']) { assert.doesNotMatch(source, new RegExp(`context\\.request\\(['"]${method}['"]`)) } for (const endpoint of ['/scheduler', '/attempts', '/manual-results', '/internal', '/pda']) { @@ -65,12 +74,14 @@ test('readonly module statically forbids writes and internal execution endpoints } }) -test('canonical task and node sets are locked to the phase-4 read contract', () => { - assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) +test('canonical task node and exception sets are locked to the phase-4 contract', () => { + assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ - 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', - 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', + 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', + 'PICKED_UP', 'EMPTY_RETURNED', ]) + assert.deepEqual(EXCEPTION_CODES, ['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) }) test('empty page validates read-only pagination without detail calls', async () => { @@ -90,10 +101,12 @@ test('existing task reads page, detail and history with nullable node times', as const context = contextFor({ [`${base}/carriers`]: carriers, [`${base}?page=1&size=20`]: { records: [{ query: task, expired: false }], page: 1, size: 20, total: 1, pages: 1 }, - [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], histories: [history] }, + [`${base}/${task.id}`]: { query: { query: task, expired: false }, nodes: [node], exceptions: [exception], histories: [history] }, + [`${base}/${task.id}/manual-nodes`]: { query: { query: task, expired: false }, nodes: [node, manualNode], exceptions: [exception], histories: [history] }, [`${base}/${task.id}/history`]: [history], }) await run(context) - assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'GET']) + assert.deepEqual(context.calls.map((item) => item.method), ['GET', 'GET', 'GET', 'POST', 'GET']) + assert.equal(context.calls.find((item) => item.method === 'POST').headers['Idempotency-Key'], `T-PHASE4-carrier-manual-${task.id}`) assert.equal(context.calls.at(-1).path, `${base}/${task.id}/history`) }) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index bf02e09d2..14d16cc54 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -2,11 +2,13 @@ const MODULE = 'phase4-carrier-nodes' const OPTIONS = { scenario: 'phase4-carrier-nodes-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' -export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'ARRIVED', 'PICKUP_READY', 'STOPPED']) +export const QUERY_STATUSES = Object.freeze(['READY', 'MANUAL_REQUIRED', 'STOPPED']) export const STANDARD_NODES = Object.freeze([ - 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'DEPARTED_ORIGIN_PORT', - 'IN_TRANSIT', 'ARRIVED', 'PICKUP_READY', + 'DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', + 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY', + 'PICKED_UP', 'EMPTY_RETURNED', ]) +export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD']) function id(value) { return value == null ? '' : String(value) } function number(value) { return Number.isFinite(Number(value)) } @@ -18,21 +20,32 @@ function validTask(task) { return Boolean(id(task?.id) && task?.queryNo && id(task?.customerId) && id(task?.oceanBookingId)) && QUERY_STATUSES.includes(task?.status) && nullableId(task?.containerBookingId) - && nullableText(task?.latestStandardNode) - && (task?.latestStandardNode == null || STANDARD_NODES.includes(task.latestStandardNode)) - && ['latestNodeObservedAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] + && nullableText(task?.latestActualNode) + && (task?.latestActualNode == null || STANDARD_NODES.includes(task.latestActualNode)) + && number(task?.version) + && ['latestActualAt', 'lastSuccessAt', 'sourceUpdatedAt', 'expiresAt', 'stoppedAt'] .every((field) => nullableIso(task?.[field])) && nullableText(task?.stopReason) } function validNode(node) { - return Boolean(id(node?.id) && node?.providerCode && node?.providerNodeCode) + return Boolean(id(node?.id) && node?.providerCode && node?.providerEventId && node?.providerNodeCode) && STANDARD_NODES.includes(node?.standardNode) && number(node?.nodeSequence) && Number(node.nodeSequence) > 0 && nullableIso(node?.observedAt) && node?.observedAt != null && nullableIso(node?.estimatedAt) && nullableIso(node?.actualAt) && nullableText(node?.locationCode) && nullableText(node?.locationName) && nullableText(node?.sourceReference) && nullableIso(node?.createdAt) + && typeof node?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(node.rawPayloadHash) +} + +function validException(value) { + return Boolean(id(value?.id) && value?.providerCode && value?.providerEventId && value?.providerStatus) + && EXCEPTION_CODES.includes(value?.exceptionCode) && typeof value?.active === 'boolean' + && nullableIso(value?.observedAt) && value?.observedAt != null + && nullableText(value?.locationCode) && nullableText(value?.locationName) + && nullableText(value?.sourceReference) && nullableIso(value?.createdAt) + && typeof value?.rawPayloadHash === 'string' && /^[0-9a-f]{64}$/.test(value.rawPayloadHash) } function validHistory(item) { @@ -71,12 +84,33 @@ export async function run(context) { const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) const detail = context.apiSuccess(MODULE, '查询真实船节点任务详情', detailResponse, `queryId=${queryId}`, OPTIONS) const nodes = Array.isArray(detail?.nodes) ? detail.nodes : [] + const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : [] const histories = Array.isArray(detail?.histories) ? detail.histories : [] context.assert(MODULE, '详情返回Task、标准节点与业务历史', detailResponse, validTask(detail?.query?.query) && typeof detail?.query?.expired === 'boolean' && Array.isArray(detail?.nodes) && nodes.every(validNode) + && Array.isArray(detail?.exceptions) && exceptions.every(validException) && Array.isArray(detail?.histories) && histories.every(validHistory), - `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, histories=${histories.length}`, OPTIONS) + `status=${detail?.query?.query?.status || 'missing'}, nodes=${nodes.length}, exceptions=${exceptions.length}, histories=${histories.length}`, OPTIONS) + + const task = detail.query.query + const observedAt = task.latestActualAt || '2026-08-21T00:00:00' + const manualResponse = await context.request('POST', `${BASE}/${encodeURIComponent(queryId)}/manual-nodes`, { + expectedVersion: task.version, + standardNode: task.latestActualNode || 'DEPARTED_WAREHOUSE', + observedAt, + estimatedAt: observedAt, + actualAt: null, + locationCode: null, + locationName: null, + reason: '四期船节点人工预计事实验收', + }, context.token, { 'Idempotency-Key': `${context.marker || 'phase4'}-carrier-manual-${queryId}`.slice(0, 128) }) + const manualDetail = context.apiSuccess(MODULE, '人工补录预计节点事实', manualResponse, `queryId=${queryId}`, OPTIONS) + context.assert(MODULE, '人工预计事实可见且不改变任务操作态', manualResponse, + QUERY_STATUSES.includes(manualDetail?.query?.query?.status) + && manualDetail?.nodes?.some((item) => item?.providerCode === 'MANUAL' + && item?.estimatedAt != null && item?.actualAt == null), + `status=${manualDetail?.query?.query?.status || 'missing'}, nodes=${manualDetail?.nodes?.length ?? 'missing'}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}/history`) const history = context.apiSuccess(MODULE, '查询真实船节点任务历史', historyResponse, `queryId=${queryId}`, OPTIONS) From cd477fe40a51477b23876ea8ba455bc00d6937d1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 16:15:02 +0800 Subject: [PATCH 035/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/firstleg-service.yml | 8 +++ config/nacos/local/firstleg-service.yml | 7 ++ deploy/docker-compose.yml | 7 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 57 +++++++++++----- firstleg/AGENTS.md | 2 +- .../CarrierScheduleQueryScheduler.java | 27 +++++++- .../service/CarrierScheduleQueryService.java | 55 ++++++--------- .../service/CarrierScheduleQuerySlots.java | 20 ++++++ firstleg/src/main/resources/application.yml | 8 +++ .../phase3-carrier-schedule-query-schema.sql | 16 ++--- .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../CarrierScheduleQuerySqlContractTest.java | 10 ++- .../CarrierScheduleQuerySchedulerTest.java | 67 +++++++++++++++++++ .../CarrierScheduleQueryServiceTest.java | 31 ++++++++- .../CarrierScheduleQuerySlotsTest.java | 24 +++++++ .../phase3-carrier-schedule-queries.mjs | 9 ++- 16 files changed, 278 insertions(+), 72 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index b94804393..966bce17e 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -16,6 +16,14 @@ baiyue: cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} + cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} + cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} + lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} lastleg: event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index c94b2aa41..dae585d47 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -12,6 +12,13 @@ baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABL baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} +baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} +baiyue.carrier-schedule.scheduler.cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} +baiyue.carrier-schedule.scheduler.cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} +baiyue.carrier-schedule.scheduler.cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} +baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} +baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} +baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} firstleg: inbound: allow-rejected-measurement: false diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 93d9a5eb0..3de6fc532 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -99,6 +99,13 @@ services: COSCO_OPEN_API_ENABLED: ${COSCO_OPEN_API_ENABLED:-false} EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} + CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} + CARRIER_SCHEDULE_CRON_MORNING: ${CARRIER_SCHEDULE_CRON_MORNING:-0 0 9 * * *} + CARRIER_SCHEDULE_CRON_NOON: ${CARRIER_SCHEDULE_CRON_NOON:-0 30 12 * * *} + CARRIER_SCHEDULE_CRON_AFTERNOON: ${CARRIER_SCHEDULE_CRON_AFTERNOON:-0 0 16 * * *} + CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} + CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} + CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} finance: <<: *common diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f5309a87d..0fb4f2dfc 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10253,10 +10253,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', + window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', + next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10296,14 +10296,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), - CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) +) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -10319,11 +10318,39 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_fixed_slots() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_attempt; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_attempt' + AND constraint_name='chk_carrier_schedule_attempt_no' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query_attempt DROP CHECK chk_carrier_schedule_attempt_no; + END IF; +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_fixed_slots(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; + +SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); +UPDATE fl_carrier_schedule_query + SET next_allowed_at = CASE + WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR + WHEN TIME(@carrier_schedule_now) < '12:30:00' THEN DATE(@carrier_schedule_now) + INTERVAL 12 HOUR + INTERVAL 30 MINUTE + WHEN TIME(@carrier_schedule_now) < '16:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 16 HOUR + ELSE DATE(@carrier_schedule_now + INTERVAL 1 DAY) + INTERVAL 9 HOUR + END + WHERE status IN ('READY','MANUAL_REQUIRED'); + CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', @@ -11928,7 +11955,7 @@ CALL queue_table_comment('baiyue_finance', 'fin_receipt_bill', '人工收款对 CALL queue_table_comment('baiyue_finance', 'fin_subject', '平台财务主体;首期仅一个有效主体'); CALL queue_table_comment('baiyue_firstleg', 'fl_assignment_history', '头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_fba_rate', '船司FBA目的仓价格版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司官网查询任务与三天窗口频控'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司固定时刻自动查询任务'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); @@ -12515,7 +12542,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前查询窗口已执行次数;最多3次'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '累计自动查询调用次数'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_scope_id', '柜次唯一作用域计算值;整票订舱查询固定为0'); @@ -12531,7 +12558,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_error_message', '最近查询失败错误说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_success_at', '最近一次取得有效结果时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'manual_reason', '需要人工查询或录入的原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '频控规则允许的下次查询时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一个09:00、12:30或16:00固定查询槽位;停止后为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'ocean_booking_id', '关联海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'official_url', '船公司官网查询入口地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query_no', '船司官网查询任务编号'); @@ -12546,14 +12573,14 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopp CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数,用于指数退避'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数诊断值;不改变固定查询槽位'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by_name', '最后修改操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前三天查询窗口起始时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '首次进入自动查询调度的时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); @@ -12568,7 +12595,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_id', '执行本次尝试的操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前三天窗口内的尝试序号;取值1至3'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '任务累计自动查询调用序号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index ea318b6a5..467b7bf9a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -51,7 +51,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 仅在 `Asia/Shanghai` 每日 09:00、12:30、16:00 三个固定槽位自动查询;本轮结果无论成功、无变化或失败都进入下一固定槽位,不额外补跑或指数退避,实际 `EMPTY_RETURNED` 后停止;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 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 28c72c2a1..5e3a0ebe1 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 @@ -13,6 +13,7 @@ import org.springframework.stereotype.Component; import java.time.Clock; import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import java.util.UUID; @@ -26,6 +27,9 @@ public class CarrierScheduleQueryScheduler { @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20; + @Value("${baiyue.carrier-schedule.scheduler.max-tasks-per-trigger:2000}") + private int maxTasksPerTrigger = 2000; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") private String scheduleZone = "Asia/Shanghai"; @Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}") private Duration leaseDuration = Duration.ofMinutes(5); @@ -35,10 +39,27 @@ public class CarrierScheduleQueryScheduler { this.repository = repository; this.service = service; this.integration = integration; } - @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}", + zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") public void poll() { - LocalDateTime now = LocalDateTime.now(clock); - repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now)); + LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); + int batch = Math.max(1, Math.min(batchSize, 100)); + int safetyLimit = Math.max(1, maxTasksPerTrigger); + int scanned = 0; + while (scanned < safetyLimit) { + List candidates = repository.due(triggerAt, + Math.min(batch, safetyLimit - scanned)); + if (candidates.isEmpty()) return; + for (CarrierScheduleQueryEntity candidate : candidates) { + execute(candidate, triggerAt); scanned++; + if (scanned >= safetyLimit) return; + } + if (candidates.size() < batch) return; + } } /** Executes only after this worker wins the database lease. */ 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 69315989c..94983631d 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 @@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -43,6 +44,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Clock; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HexFormat; import java.util.List; import java.util.Locale; @@ -51,14 +53,14 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { - static final int MAX_ATTEMPTS = 3; - static final int WINDOW_DAYS = 3; private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @Resource private PortScheduleRepository portSchedules; @Resource private CarrierScheduleAdapterRegistry adapters; @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; private Clock clock = Clock.systemDefaultZone(); public List carriers() { return adapters.descriptors(); } @@ -114,7 +116,8 @@ public class CarrierScheduleQueryService { value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM"); value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name()); value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); - value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } catch (DuplicateKeyException exception) { @@ -206,7 +209,7 @@ public class CarrierScheduleQueryService { value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason("已还柜"); value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null); } else { - value.setStatus("READY"); value.setNextAllowedAt(now().plusDays(WINDOW_DAYS)); + value.setStatus("READY"); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now())); } if (repository.updateState(value, value.getVersion(), operatorId, operatorName) != 1) throw concurrent(); return true; @@ -224,31 +227,13 @@ public class CarrierScheduleQueryService { value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); - if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) { - value.setStatus("READY"); - value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now); - value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); - } else { - int failures = count(value.getConsecutiveFailures()) + 1; - LocalDateTime windowStart = value.getWindowStartedAt(); - if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - windowStart = now; value.setAttemptCount(0); - } - int attempts = count(value.getAttemptCount()) + 1; - value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts)); - value.setConsecutiveFailures(failures); - if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) { - value.setStatus("MANUAL_REQUIRED"); - value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); - } else { - long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6)); - LocalDateTime retryAt = now.plusMinutes(minutes); - if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) { - retryAt = windowStart.plusDays(WINDOW_DAYS); - } - value.setNextAllowedAt(retryAt); - } - } + boolean success = "SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome); + value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) + ? "MANUAL_REQUIRED" : "READY"); + value.setConsecutiveFailures(success ? 0 : increment(value.getConsecutiveFailures())); + value.setAttemptCount(increment(value.getAttemptCount())); + if (value.getWindowStartedAt() == null) value.setWindowStartedAt(now); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); CarrierScheduleQueryEntity after = required(id); addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, @@ -259,7 +244,7 @@ public class CarrierScheduleQueryService { String errorCode, String errorMessage) { CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); - attempt.setWindowAttemptNo(Math.max(1, Math.min(MAX_ATTEMPTS, count(value.getAttemptCount()) + 1))); + attempt.setWindowAttemptNo(increment(value.getAttemptCount())); attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); boolean manual = "MANUAL_REQUIRED".equals(outcome); @@ -287,7 +272,8 @@ public class CarrierScheduleQueryService { value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); value.setStatus("READY"); value.setTriggerSource("MANUAL"); value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0); - value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); try { repository.insert(value); } @@ -314,7 +300,7 @@ public class CarrierScheduleQueryService { LocalDateTime now = now(); value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); - value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS)); + value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null); if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); @@ -505,8 +491,9 @@ public class CarrierScheduleQueryService { return normalized; } - private LocalDateTime now() { return LocalDateTime.now(clock); } - private static int count(Integer value) { return value == null ? 0 : value; } + private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); } + private static int increment(Integer value) { return value == null || value < 0 ? 1 + : value == Integer.MAX_VALUE ? Integer.MAX_VALUE : value + 1; } private static void validateKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java new file mode 100644 index 000000000..9165c9714 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.portschedule.service; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +public final class CarrierScheduleQuerySlots { + private static final LocalTime MORNING = LocalTime.of(9, 0); + private static final LocalTime NOON = LocalTime.of(12, 30); + private static final LocalTime AFTERNOON = LocalTime.of(16, 0); + + private CarrierScheduleQuerySlots() { } + + /** Returns the first fixed slot strictly after {@code now}. Exact slot boundaries advance. */ + public static LocalDateTime nextSlot(LocalDateTime now) { + if (now.toLocalTime().isBefore(MORNING)) return now.toLocalDate().atTime(MORNING); + if (now.toLocalTime().isBefore(NOON)) return now.toLocalDate().atTime(NOON); + if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON); + return now.toLocalDate().plusDays(1).atTime(MORNING); + } +} diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index e3aa7af0b..226cc98ae 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -33,6 +33,14 @@ baiyue: cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} emc-automated: ${EMC_TRACKING_ENABLED:false} hede-automated: ${HEDE_TRACKING_ENABLED:false} + scheduler: + zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} + cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} + cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} + cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} + max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} + lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index c9062effe..7a9ec3fc5 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -21,10 +21,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避', - window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次', - next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', + window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', + next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -64,14 +64,13 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), - CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) -) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) +) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -87,7 +86,6 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), - CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 1788868a2..2a8b46bed 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -25,7 +25,7 @@ paths: - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} responses: - '200': {description: Query tasks with source, freshness and rate-limit fields} + '200': {description: Query tasks with source and freshness fields; automatic execution uses the fixed Shanghai slots} post: summary: Create a query task linked to a real booking and allocated container x-permission: platform:firstleg:port-schedule-query:create 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 ffb9c84fd..d707b84f7 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 @@ -19,8 +19,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") - .contains("attempt_count BETWEEN 0 AND 3") - .contains("window_attempt_no BETWEEN 1 AND 3") + .contains("累计自动查询调用次数") + .contains("下一个09:00、12:30或16:00固定查询槽位") + .doesNotContain("attempt_count BETWEEN 0 AND 3") + .doesNotContain("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") @@ -61,6 +63,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("uk_carrier_schedule_query_scope") .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") + .contains("migrate_carrier_schedule_fixed_slots") + .contains("DROP CHECK chk_carrier_schedule_query_attempt") + .contains("DROP CHECK chk_carrier_schedule_attempt_no") + .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("船司查询任务角色授权未完整创建") .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); 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 6b4c92c6e..fd95bc508 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 @@ -4,18 +4,81 @@ import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient; import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository; import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class CarrierScheduleQuerySchedulerTest { + @Test + void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception { + Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class); + assertThat(schedules).hasSize(3); + assertThat(schedules).extracting(Scheduled::cron).containsExactlyInAnyOrder( + "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", + "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", + "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}"); + assertThat(schedules).extracting(Scheduled::zone) + .containsOnly("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}"); + } + + @Test + void drainsControlledBatchesAndStopsAtThePerTriggerSafetyLimit() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + CarrierScheduleQueryEntity first = task(1L); CarrierScheduleQueryEntity second = task(2L); + CarrierScheduleQueryEntity third = task(3L); + when(repository.due(any(), anyInt())).thenReturn(List.of(first, second), List.of(third)); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 3); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T01:00:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 2); + verify(repository).due(LocalDateTime.of(2026, 8, 21, 9, 0), 1); + verify(scheduler, times(3)).execute(any(), any()); + } + + @Test + void keepsClaimingControlledBatchesUntilNoDueTasksRemain() { + CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); + CarrierScheduleQueryScheduler scheduler = spy(new CarrierScheduleQueryScheduler(repository, + mock(CarrierScheduleQueryService.class), mock(CarrierScheduleIntegrationClient.class))); + when(repository.due(any(), anyInt())).thenReturn(List.of(task(1L), task(2L)), List.of()); + doNothing().when(scheduler).execute(any(), any()); + ReflectionTestUtils.setField(scheduler, "batchSize", 2); + ReflectionTestUtils.setField(scheduler, "maxTasksPerTrigger", 10); + ReflectionTestUtils.setField(scheduler, "scheduleZone", "Asia/Shanghai"); + ReflectionTestUtils.setField(scheduler, "clock", + Clock.fixed(Instant.parse("2026-08-21T04:30:00Z"), ZoneOffset.UTC)); + + scheduler.poll(); + + verify(repository, times(2)).due(LocalDateTime.of(2026, 8, 21, 12, 30), 2); + verify(scheduler, times(2)).execute(any(), any()); + } + @Test void leaseWinnerPersistsStandardNodesAndCompletesAttempt() { CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class); @@ -42,4 +105,8 @@ class CarrierScheduleQuerySchedulerTest { verify(service).applyProviderException(anyLong(), any()); verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null); } + + 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 9930d54c5..70d143cc3 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 @@ -71,7 +71,9 @@ class CarrierScheduleQueryServiceTest { assertThat(result.query().query().carrierCode()).isEqualTo("ZIM"); assertThat(result.query().query().status()).isEqualTo("READY"); assertThat(result.query().expired()).isTrue(); - verify(repository).insert(any(CarrierScheduleQueryEntity.class)); + ArgumentCaptor created = ArgumentCaptor.forClass(CarrierScheduleQueryEntity.class); + verify(repository).insert(created.capture()); + assertThat(created.getValue().getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); } @Test @@ -90,6 +92,7 @@ class CarrierScheduleQueryServiceTest { assertThat(result.getCarrierCode()).isEqualTo("COSCO"); assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); + assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); } @@ -212,7 +215,31 @@ class CarrierScheduleQueryServiceTest { verify(repository).insert(attempt.capture()); assertThat(attempt.getValue().getAttemptStatus()).isEqualTo("NO_CHANGE"); assertThat(attempt.getValue().getOperatorName()).isEqualTo("carrier-scheduler"); + assertThat(attempt.getValue().getWindowAttemptNo()).isEqualTo(1); assertThat(task.getStatus()).isEqualTo("READY"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + } + + @Test + void everySchedulerOutcomeUsesTheNextFixedSlotWithoutExtraRetryTimes() { + List outcomes = List.of("SUCCESS", "NO_CHANGE", "MANUAL_REQUIRED", "RETRYABLE_FAILURE", + "PERMANENT_FAILURE"); + List tasks = outcomes.stream().map(value -> task(0, 0)).toList(); + when(repository.lock(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.find(1L)).thenReturn(tasks.get(0), tasks.get(1), tasks.get(2), tasks.get(3), tasks.get(4)); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null)); + + assertThat(tasks).allSatisfy(value -> { + assertThat(value.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(value.getAttemptCount()).isEqualTo(1); + }); + assertThat(tasks.get(0).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(1).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED"); + assertThat(tasks.get(3).getStatus()).isEqualTo("READY"); + assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED"); } @Test @@ -291,7 +318,7 @@ class CarrierScheduleQueryServiceTest { value.setId(1L); value.setQueryNo("CSQ1"); value.setCustomerId(10001L); value.setOceanBookingId(10L); value.setContainerBookingId(20L); value.setCarrierCode("ZIM"); value.setTrackingType("BILL_OF_LADING"); value.setTrackingNo("GAOU6898053"); - value.setOfficialUrl("https://www.zimchina.com/tools/track-a-shipment"); value.setAccessMode("HCAPTCHA"); + value.setOfficialUrl("https://api.zim.com/"); value.setAccessMode("OFFICIAL_API"); value.setStatus("MANUAL_REQUIRED"); value.setWindowStartedAt(NOW.minusDays(1)); value.setAttemptCount(attempts); value.setNextAllowedAt(NOW); value.setVersion(version); return value; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java new file mode 100644 index 000000000..31e276927 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.portschedule.service; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class CarrierScheduleQuerySlotsTest { + @Test + void returnsTheFirstStrictlyLaterShanghaiSlotAtExactBoundaries() { + assertThat(CarrierScheduleQuerySlots.nextSlot(at(8, 59, 59))).isEqualTo(at(9, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(9, 0, 0))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 29, 59))).isEqualTo(at(12, 30, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(12, 30, 0))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0)); + assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0))) + .isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0)); + } + + private static LocalDateTime at(int hour, int minute, int second) { + return LocalDateTime.of(2026, 8, 21, hour, minute, second); + } +} diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index d040f1727..d89e29f2d 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -120,10 +120,9 @@ export async function run(context) { const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) - context.assert(MODULE, '查询任务初始来源、频控和过期字段稳定', createResponse, - Boolean(queryId) && created?.query?.query?.status === 'READY' && created?.query?.query?.attemptCount === 0 - && created?.query?.maxAttempts === 3 && created?.query?.expired === true - && Boolean(created?.query?.nextAllowedAt) && created?.query?.query?.accessMode === 'HCAPTCHA', + context.assert(MODULE, '查询任务初始来源和过期字段稳定', createResponse, + Boolean(queryId) && created?.query?.query?.status === 'READY' + && created?.query?.expired === true && created?.query?.query?.accessMode === 'OFFICIAL_API', `queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS) if (!queryId) return @@ -157,7 +156,7 @@ export async function run(context) { context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, completed?.query?.query?.status === 'READY' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) - && Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false + && completed?.query?.expired === false && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS) From 8c280e19c4dc58ac4eb66a17e090ad161e5662cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 17:05:52 +0800 Subject: [PATCH 036/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/firstleg-service.yml | 5 +- config/nacos/local/firstleg-service.yml | 5 +- deploy/docker-compose.yml | 5 +- deploy/sql/README.md | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 211 ++++++++++++++---- firstleg/AGENTS.md | 2 +- .../CarrierScheduleQueryScheduler.java | 9 +- .../service/CarrierScheduleQueryService.java | 66 +++++- .../service/CarrierScheduleQuerySlots.java | 24 ++ firstleg/src/main/resources/application.yml | 5 +- .../phase3-carrier-schedule-query-schema.sql | 16 +- .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../CarrierScheduleQuerySqlContractTest.java | 28 ++- .../CarrierScheduleQuerySchedulerTest.java | 16 +- .../CarrierScheduleQueryServiceTest.java | 102 ++++++++- .../CarrierScheduleQuerySlotsTest.java | 4 + .../Phase3FinalUnifiedSqlContractTest.java | 6 + ...ergreenCarrierScheduleProviderAdapter.java | 6 +- 18 files changed, 411 insertions(+), 103 deletions(-) diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 966bce17e..910720455 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -18,9 +18,8 @@ baiyue: hede-automated: ${HEDE_TRACKING_ENABLED:false} scheduler: zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} - cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} - cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} - cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index dae585d47..1123e5927 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -13,9 +13,8 @@ baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_E baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} -baiyue.carrier-schedule.scheduler.cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} -baiyue.carrier-schedule.scheduler.cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} -baiyue.carrier-schedule.scheduler.cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} +baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} +baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3de6fc532..385efc81b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -100,9 +100,8 @@ services: EMC_TRACKING_ENABLED: ${EMC_TRACKING_ENABLED:-false} HEDE_TRACKING_ENABLED: ${HEDE_TRACKING_ENABLED:-false} CARRIER_SCHEDULE_ZONE: ${CARRIER_SCHEDULE_ZONE:-Asia/Shanghai} - CARRIER_SCHEDULE_CRON_MORNING: ${CARRIER_SCHEDULE_CRON_MORNING:-0 0 9 * * *} - CARRIER_SCHEDULE_CRON_NOON: ${CARRIER_SCHEDULE_CRON_NOON:-0 30 12 * * *} - CARRIER_SCHEDULE_CRON_AFTERNOON: ${CARRIER_SCHEDULE_CRON_AFTERNOON:-0 0 16 * * *} + CARRIER_SCHEDULE_SCAN_FIXED_DELAY: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:-PT1M} + CARRIER_SCHEDULE_SCAN_INITIAL_DELAY: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:-PT10S} CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e120a9295..e06944ed6 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -58,6 +58,8 @@ 在 Navicat、DataGrip 或 MySQL 命令行中以“运行脚本”方式完整执行,确保客户端支持 `DELIMITER`。不要只选中局部语句执行。 +执行前先重新连接目标数据库,开启客户端 keepalive,并关闭“遇错继续执行”;出现连接断开、语法错误或 `SQLSTATE 45000` 后不得从失败位置续跑。由于 DDL 会隐式提交,存量环境可能已经部分更新;修复失败原因后应使用同一固定入口从第一行完整重跑,依靠脚本的元数据判断和幂等写入收敛结构。若出现 MySQL `4031`,先处理客户端连接保活或由数据库管理员调整 `wait_timeout` / `interactive_timeout`,再重新连接执行。 + 执行结束后必须看到 `migration_status = PASS`。如果中途出现 `SQLSTATE 45000`,必须按错误中给出的缺失表、字段或权限处理后重新完整执行。 ## 三期分柜、预装、拣货与装柜当前基线 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0fb4f2dfc..8afc2bb8f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10115,7 +10115,7 @@ BEGIN OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102170 AND 5102175 AND permission_code LIKE 'platform:firstleg:customs:%') <> 6 OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' - AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 4 THEN + AND id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期权限未完整写入'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' @@ -10130,7 +10130,7 @@ BEGIN AND (p.permission_code LIKE 'platform:firstleg:outbound-dispatch:%' OR p.permission_code LIKE 'platform:firstleg:customs:%' OR p.permission_code LIKE 'platform:firstleg:port-schedule:%' - OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 38 THEN + OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:三期出仓、报关或船期角色授权未完整写入'; END IF; END$$ @@ -10253,10 +10253,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', - window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', - next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位内的连续技术失败次数', + window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位已调用次数;最多3次', + next_allowed_at DATETIME(3) NULL COMMENT '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -10296,13 +10296,14 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) -) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) +) ENGINE=InnoDB COMMENT='六家船公司固定槽位与技术退避查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -10318,39 +10319,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; -DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; -DELIMITER $$ -CREATE PROCEDURE migrate_carrier_schedule_fixed_slots() -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.table_constraints - WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' - AND constraint_name='chk_carrier_schedule_query_attempt' AND constraint_type='CHECK') THEN - ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_attempt; - END IF; - IF EXISTS (SELECT 1 FROM information_schema.table_constraints - WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_attempt' - AND constraint_name='chk_carrier_schedule_attempt_no' AND constraint_type='CHECK') THEN - ALTER TABLE fl_carrier_schedule_query_attempt DROP CHECK chk_carrier_schedule_attempt_no; - END IF; -END$$ -DELIMITER ; -CALL migrate_carrier_schedule_fixed_slots(); -DROP PROCEDURE IF EXISTS migrate_carrier_schedule_fixed_slots; - -SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); -UPDATE fl_carrier_schedule_query - SET next_allowed_at = CASE - WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR - WHEN TIME(@carrier_schedule_now) < '12:30:00' THEN DATE(@carrier_schedule_now) + INTERVAL 12 HOUR + INTERVAL 30 MINUTE - WHEN TIME(@carrier_schedule_now) < '16:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 16 HOUR - ELSE DATE(@carrier_schedule_now + INTERVAL 1 DAY) + INTERVAL 9 HOUR - END - WHERE status IN ('READY','MANUAL_REQUIRED'); - CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', @@ -10568,6 +10541,138 @@ DELIMITER ; CALL assert_carrier_schedule_query_schema(); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; +DELIMITER $$ +CREATE PROCEDURE migrate_carrier_schedule_retry_columns() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='trigger_source') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' + COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_standard_node') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='latest_node_observed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN latest_node_observed_at DATETIME(3) NULL COMMENT '最近实际标准节点发生时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stopped_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stopped_at DATETIME(3) NULL COMMENT '任务停止时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='stop_reason') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN stop_reason VARCHAR(500) NULL COMMENT '任务停止原因'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_owner') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='lease_until') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='consecutive_failures') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='window_started_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN window_started_at DATETIME(3) NULL COMMENT '当前正常查询槽位时间'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='attempt_count') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND column_name='next_allowed_at') THEN + ALTER TABLE fl_carrier_schedule_query + ADD COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due') THEN + ALTER TABLE fl_carrier_schedule_query + ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id); + END IF; +END$$ +DELIMITER ; +CALL migrate_carrier_schedule_retry_columns(); +DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; + +SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00'); +SET @carrier_schedule_next = CASE + WHEN TIME(@carrier_schedule_now) < '09:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 9 HOUR + WHEN TIME(@carrier_schedule_now) < '12:30:00' THEN DATE(@carrier_schedule_now) + INTERVAL 12 HOUR + INTERVAL 30 MINUTE + WHEN TIME(@carrier_schedule_now) < '16:00:00' THEN DATE(@carrier_schedule_now) + INTERVAL 16 HOUR + ELSE DATE(@carrier_schedule_now + INTERVAL 1 DAY) + INTERVAL 9 HOUR + END; +UPDATE fl_carrier_schedule_query + SET next_allowed_at=@carrier_schedule_next,window_started_at=@carrier_schedule_next, + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status IN ('READY','MANUAL_REQUIRED'); +UPDATE fl_carrier_schedule_query + SET next_allowed_at=NULL, + window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=0,consecutive_failures=0,lease_owner=NULL,lease_until=NULL + WHERE status='STOPPED'; +UPDATE fl_carrier_schedule_query + SET window_started_at=COALESCE(window_started_at,@carrier_schedule_next), + attempt_count=LEAST(COALESCE(attempt_count,0),3), + consecutive_failures=COALESCE(consecutive_failures,0); + +ALTER TABLE fl_carrier_schedule_query + MODIFY COLUMN lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', + MODIFY COLUMN lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', + MODIFY COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位内的连续技术失败次数', + MODIFY COLUMN window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + MODIFY COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0 + COMMENT '当前正常查询槽位已调用次数;最多3次', + MODIFY COLUMN next_allowed_at DATETIME(3) NULL + COMMENT '下一正常槽位或槽内技术退避时间;停止后为空'; + +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; +DELIMITER $$ +CREATE PROCEDURE ensure_carrier_schedule_retry_check() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' AND constraint_type='CHECK') THEN + ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3); + END IF; +END$$ +DELIMITER ; +CALL ensure_carrier_schedule_retry_check(); +DROP PROCEDURE IF EXISTS ensure_carrier_schedule_retry_check; + -- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql -- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 @@ -11955,7 +12060,7 @@ CALL queue_table_comment('baiyue_finance', 'fin_receipt_bill', '人工收款对 CALL queue_table_comment('baiyue_finance', 'fin_subject', '平台财务主体;首期仅一个有效主体'); CALL queue_table_comment('baiyue_firstleg', 'fl_assignment_history', '头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_fba_rate', '船司FBA目的仓价格版本'); -CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司固定时刻自动查询任务'); +CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家船公司固定槽位与技术退避查询任务'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史'); CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要'); @@ -12542,7 +12647,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'updated_by' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'version', '乐观锁版本号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_fba_rate', 'weight_from_kg', '区域阶梯起始计费重量KG'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'access_mode', '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '累计自动查询调用次数'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'attempt_count', '当前正常查询槽位已调用次数;最多3次'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'carrier_code', '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_booking_id', '指定查询的柜次ID;整票订舱查询时为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'container_scope_id', '柜次唯一作用域计算值;整票订舱查询固定为0'); @@ -12558,7 +12663,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_error_message', '最近查询失败错误说明'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'last_success_at', '最近一次取得有效结果时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'manual_reason', '需要人工查询或录入的原因'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一个09:00、12:30或16:00固定查询槽位;停止后为空'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'next_allowed_at', '下一正常槽位或槽内技术退避时间;停止后为空'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'ocean_booking_id', '关联海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'official_url', '船公司官网查询入口地址快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query_no', '船司官网查询任务编号'); @@ -12573,14 +12678,14 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopp CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数诊断值;不改变固定查询槽位'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '当前正常查询槽位内的连续技术失败次数'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by', '最后修改操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_by_name', '最后修改操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'version', '乐观锁版本号'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '首次进入自动查询调度的时间'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'window_started_at', '当前正常查询槽位时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'access_mode', '本次尝试的官网访问方式快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'attempt_status', '供应商调用结果'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'carrier_code', '本次尝试的船公司编码快照'); @@ -12595,7 +12700,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt' CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_id', '执行本次尝试的操作人ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'operator_name', '执行操作人姓名快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'query_id', '所属船司官网查询任务ID'); -CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '任务累计自动查询调用序号'); +CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', 'window_attempt_no', '当前正常查询槽位内的调用序号;取值1至3'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'action', '查询任务业务历史动作'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'after_snapshot', '变更后任务数据快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', 'before_snapshot', '变更前任务数据快照;创建时为空'); @@ -14598,6 +14703,22 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', + 'stopped_at','stop_reason','lease_owner','lease_until','consecutive_failures', + 'window_started_at','attempt_count','next_allowed_at')) <> 11 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND index_name='idx_carrier_schedule_query_due' + AND column_name IN ('status','next_allowed_at','id')) <> 3 + OR (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE constraint_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_attempt' + AND constraint_type='CHECK') <> 1 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整'; + END IF; IF EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema IN ('baiyue_auth','baiyue_iam','baiyue_order','baiyue_warehouse', @@ -15973,7 +16094,7 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; -+-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) +-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql) -- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 467b7bf9a..475f7a44a 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -51,7 +51,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 仅在 `Asia/Shanghai` 每日 09:00、12:30、16:00 三个固定槽位自动查询;本轮结果无论成功、无变化或失败都进入下一固定槽位,不额外补跑或指数退避,实际 `EMPTY_RETURNED` 后停止;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 +- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 - `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 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 5e3a0ebe1..1a497bbc3 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 @@ -39,12 +39,8 @@ public class CarrierScheduleQueryScheduler { this.repository = repository; this.service = service; this.integration = integration; } - @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", - zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") - @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", - zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") - @Scheduled(cron = "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}", - zone = "${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + @Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}", + initialDelayString = "${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}") public void poll() { LocalDateTime triggerAt = LocalDateTime.ofInstant(clock.instant(), ZoneId.of(scheduleZone)); int batch = Math.max(1, Math.min(batchSize, 100)); @@ -65,6 +61,7 @@ public class CarrierScheduleQueryScheduler { /** Executes only after this worker wins the database lease. */ void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) { if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return; + if (service.deferMissedNormalSlot(candidate.getId(), now)) return; try { var response = integration.query(internalToken, new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo())); 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 94983631d..336cc57e0 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 @@ -53,6 +53,7 @@ import java.util.Set; @Service public class CarrierScheduleQueryService { + static final int MAX_SLOT_ATTEMPTS = 3; private static final Set TRACKING_TYPES = Set.of("BOOKING_NO", "BILL_OF_LADING", "CONTAINER_NO"); @Resource private CarrierScheduleQueryRepository repository; @@ -217,34 +218,76 @@ public class CarrierScheduleQueryService { return false; } + @Transactional + public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) { + CarrierScheduleQueryEntity value = requiredLocked(id); + if ("STOPPED".equals(value.getStatus())) return false; + int attempts = value.getAttemptCount() == null ? 0 : value.getAttemptCount(); + boolean staleRetry = attempts > 0 && value.getWindowStartedAt() != null + && !scanAt.isBefore(CarrierScheduleQuerySlots.nextSlot(value.getWindowStartedAt())); + if (staleRetry) { + LocalDateTime currentSlot = CarrierScheduleQuerySlots.fixedSlotInCurrentMinute(scanAt); + if (currentSlot != null) { + value.setNextAllowedAt(currentSlot); value.setWindowStartedAt(currentSlot); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return false; + } + } else if (!CarrierScheduleQuerySlots.isMissedNormalSlot( + value.getNextAllowedAt(), value.getAttemptCount(), scanAt)) return false; + value.setLeaseOwner(null); value.setLeaseUntil(null); + value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(scanAt)); + value.setWindowStartedAt(value.getNextAllowedAt()); + value.setAttemptCount(0); value.setConsecutiveFailures(0); + if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); + return true; + } + @Transactional public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryEntity value = requiredLocked(id); - insertAttempt(value, outcome, errorCode, errorMessage); + int slotAttemptNo = Math.min(MAX_SLOT_ATTEMPTS, increment(value.getAttemptCount())); + insertAttempt(value, slotAttemptNo, outcome, errorCode, errorMessage); if ("STOPPED".equals(value.getStatus())) return; CarrierScheduleQueryEntity before = copy(value); LocalDateTime now = now(); value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now); value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode)); value.setLastErrorMessage(trim(errorMessage)); - boolean success = "SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome); - value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) - ? "MANUAL_REQUIRED" : "READY"); - value.setConsecutiveFailures(success ? 0 : increment(value.getConsecutiveFailures())); - value.setAttemptCount(increment(value.getAttemptCount())); - if (value.getWindowStartedAt() == null) value.setWindowStartedAt(now); - value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); + LocalDateTime nextFixedSlot = CarrierScheduleQuerySlots.nextSlot(now); + if ("RETRYABLE_FAILURE".equals(outcome)) { + value.setStatus("READY"); + value.setConsecutiveFailures(slotAttemptNo); + LocalDateTime retryAt = now.plusMinutes(15L << Math.max(0, slotAttemptNo - 1)); + if (slotAttemptNo < MAX_SLOT_ATTEMPTS && retryAt.isBefore(nextFixedSlot)) { + if (slotAttemptNo == 1 || value.getWindowStartedAt() == null) { + value.setWindowStartedAt(value.getNextAllowedAt()); + } + value.setAttemptCount(slotAttemptNo); value.setNextAllowedAt(retryAt); + } else { + resetForNextFixedSlot(value, nextFixedSlot); + } + } else { + value.setStatus("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome) + ? "MANUAL_REQUIRED" : "READY"); + resetForNextFixedSlot(value, nextFixedSlot); + } if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent(); CarrierScheduleQueryEntity after = required(id); addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L, "carrier-scheduler"); } - private void insertAttempt(CarrierScheduleQueryEntity value, String outcome, + private static void resetForNextFixedSlot(CarrierScheduleQueryEntity value, LocalDateTime nextFixedSlot) { + value.setConsecutiveFailures(0); value.setAttemptCount(0); + value.setWindowStartedAt(nextFixedSlot); value.setNextAllowedAt(nextFixedSlot); + } + + private void insertAttempt(CarrierScheduleQueryEntity value, int slotAttemptNo, String outcome, String errorCode, String errorMessage) { CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity(); attempt.setId(IdWorker.getId()); attempt.setQueryId(value.getId()); - attempt.setWindowAttemptNo(increment(value.getAttemptCount())); + attempt.setWindowAttemptNo(slotAttemptNo); attempt.setCarrierCode(value.getCarrierCode()); attempt.setAccessMode(value.getAccessMode()); attempt.setOfficialUrl(value.getOfficialUrl()); attempt.setAttemptStatus(outcome); boolean manual = "MANUAL_REQUIRED".equals(outcome); @@ -301,7 +344,8 @@ public class CarrierScheduleQueryService { value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId()); value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt()); value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now)); - value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED"); + value.setWindowStartedAt(value.getNextAllowedAt()); value.setAttemptCount(0); + value.setConsecutiveFailures(0); value.setLastAttemptStatus("MANUAL_COMPLETED"); value.setManualReason(trim(request.reason())); value.setLastErrorCode(null); value.setLastErrorMessage(null); if (repository.updateState(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent(); CarrierScheduleQueryEntity after = required(id); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java index 9165c9714..cc0a4d4f2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java @@ -17,4 +17,28 @@ public final class CarrierScheduleQuerySlots { if (now.toLocalTime().isBefore(AFTERNOON)) return now.toLocalDate().atTime(AFTERNOON); return now.toLocalDate().plusDays(1).atTime(MORNING); } + + public static boolean isFixedSlot(LocalDateTime value) { + if (value == null || value.getSecond() != 0 || value.getNano() != 0) return false; + return value.toLocalTime().equals(MORNING) || value.toLocalTime().equals(NOON) + || value.toLocalTime().equals(AFTERNOON); + } + + public static boolean isWithinFixedSlotMinute(LocalDateTime value) { + if (value == null) return false; + LocalTime minute = value.toLocalTime().withSecond(0).withNano(0); + return minute.equals(MORNING) || minute.equals(NOON) || minute.equals(AFTERNOON); + } + + public static LocalDateTime fixedSlotInCurrentMinute(LocalDateTime value) { + return isWithinFixedSlotMinute(value) ? value.withSecond(0).withNano(0) : null; + } + + /** A normal slot is executable only during its scheduled minute; technical retry times are unaffected. */ + public static boolean isMissedNormalSlot(LocalDateTime nextAllowedAt, Integer attemptCount, + LocalDateTime scanAt) { + return (attemptCount == null || attemptCount == 0) && isFixedSlot(nextAllowedAt) + && !isWithinFixedSlotMinute(scanAt) + && !scanAt.isBefore(nextAllowedAt.plusMinutes(1)); + } } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 226cc98ae..ed6994938 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -35,9 +35,8 @@ baiyue: hede-automated: ${HEDE_TRACKING_ENABLED:false} scheduler: zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} - cron-morning: ${CARRIER_SCHEDULE_CRON_MORNING:0 0 9 * * *} - cron-noon: ${CARRIER_SCHEDULE_CRON_NOON:0 30 12 * * *} - cron-afternoon: ${CARRIER_SCHEDULE_CRON_AFTERNOON:0 0 16 * * *} + scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} + scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} diff --git a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql index 7a9ec3fc5..f5bf83b25 100644 --- a/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql +++ b/firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql @@ -21,10 +21,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( stop_reason VARCHAR(500) NULL COMMENT '任务停止原因', lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者', lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间', - consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数诊断值;不改变固定查询槽位', - window_started_at DATETIME(3) NOT NULL COMMENT '首次进入自动查询调度的时间', - attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计自动查询调用次数', - next_allowed_at DATETIME(3) NULL COMMENT '下一个09:00、12:30或16:00固定查询槽位;停止后为空', + consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位内的连续技术失败次数', + window_started_at DATETIME(3) NOT NULL COMMENT '当前正常查询槽位时间', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前正常查询槽位已调用次数;最多3次', + next_allowed_at DATETIME(3) NULL COMMENT '下一正常槽位或槽内技术退避时间;停止后为空', last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间', last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间', source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间', @@ -64,13 +64,14 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT chk_carrier_schedule_query_node CHECK (latest_standard_node IS NULL OR latest_standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT','IN_TRANSIT', - 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')) -) ENGINE=InnoDB COMMENT='六家船公司固定时刻自动查询任务'; + 'ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), + CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) +) ENGINE=InnoDB COMMENT='六家船公司固定槽位与技术退避查询任务'; CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID', query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID', - window_attempt_no INT UNSIGNED NOT NULL COMMENT '任务累计自动查询调用序号', + window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前正常查询槽位内的调用序号;取值1至3', carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照', access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照', official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照', @@ -86,6 +87,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( PRIMARY KEY (id), KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), CONSTRAINT chk_carrier_schedule_attempt_status CHECK (attempt_status IN ('SUCCESS','NO_CHANGE','RETRYABLE_FAILURE','PERMANENT_FAILURE','MANUAL_REQUIRED')) ) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 2a8b46bed..5cf380ab9 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -25,7 +25,7 @@ paths: - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} responses: - '200': {description: Query tasks with source and freshness fields; automatic execution uses the fixed Shanghai slots} + '200': {description: Query tasks with source and freshness fields; normal execution uses fixed Shanghai slots while retryable technical failures may retry within the current slot} post: summary: Create a query task linked to a real booking and allocated container x-permission: platform:firstleg:port-schedule-query:create 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 d707b84f7..4ee740534 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 @@ -19,10 +19,10 @@ class CarrierScheduleQuerySqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact") .contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_exception_fact") - .contains("累计自动查询调用次数") - .contains("下一个09:00、12:30或16:00固定查询槽位") - .doesNotContain("attempt_count BETWEEN 0 AND 3") - .doesNotContain("window_attempt_no BETWEEN 1 AND 3") + .contains("当前正常查询槽位已调用次数;最多3次") + .contains("下一正常槽位或槽内技术退避时间") + .contains("attempt_count BETWEEN 0 AND 3") + .contains("window_attempt_no BETWEEN 1 AND 3") .contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)") .contains("船司查询尝试不可修改") .contains("船司查询历史不可删除") @@ -63,10 +63,24 @@ class CarrierScheduleQuerySqlContractTest { .contains("uk_carrier_schedule_query_scope") .contains("id IN (5102184,5102186,5102188)") .contains("assert_carrier_schedule_query_schema") - .contains("migrate_carrier_schedule_fixed_slots") - .contains("DROP CHECK chk_carrier_schedule_query_attempt") - .contains("DROP CHECK chk_carrier_schedule_attempt_no") + .contains("CREATE PROCEDURE migrate_carrier_schedule_retry_columns()") + .contains("ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL'") + .contains("ADD COLUMN latest_standard_node VARCHAR(32) NULL") + .contains("ADD COLUMN latest_node_observed_at DATETIME(3) NULL") + .contains("ADD COLUMN stopped_at DATETIME(3) NULL") + .contains("ADD COLUMN stop_reason VARCHAR(500) NULL") + .contains("ADD COLUMN consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN window_started_at DATETIME(3) NULL") + .contains("ADD COLUMN attempt_count INT UNSIGNED NOT NULL DEFAULT 0") + .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") + .contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") .contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')") + .contains("SET @carrier_schedule_next = CASE") + .contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()") + .contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)") + .contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整") + .doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt") + .doesNotContain("\n+--") .contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .contains("船司查询任务角色授权未完整创建") .doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'"); 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 fd95bc508..6c109ac37 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 @@ -27,15 +27,15 @@ import static org.mockito.Mockito.when; class CarrierScheduleQuerySchedulerTest { @Test - void declaresExactlyThreeConfigurableShanghaiCronTriggers() throws Exception { + void declaresOneConfigurableDueScannerRatherThanDirectCarrierCronCalls() throws Exception { Scheduled[] schedules = CarrierScheduleQueryScheduler.class.getMethod("poll").getAnnotationsByType(Scheduled.class); - assertThat(schedules).hasSize(3); - assertThat(schedules).extracting(Scheduled::cron).containsExactlyInAnyOrder( - "${baiyue.carrier-schedule.scheduler.cron-morning:0 0 9 * * *}", - "${baiyue.carrier-schedule.scheduler.cron-noon:0 30 12 * * *}", - "${baiyue.carrier-schedule.scheduler.cron-afternoon:0 0 16 * * *}"); - assertThat(schedules).extracting(Scheduled::zone) - .containsOnly("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}"); + assertThat(schedules).singleElement().satisfies(value -> { + assertThat(value.fixedDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-fixed-delay:PT1M}"); + assertThat(value.initialDelayString()) + .isEqualTo("${baiyue.carrier-schedule.scheduler.scan-initial-delay:PT10S}"); + assertThat(value.cron()).isEmpty(); + }); } @Test 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 70d143cc3..554fa4435 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 @@ -231,10 +231,14 @@ class CarrierScheduleQueryServiceTest { outcomes.forEach(outcome -> service.completeScheduledAttempt(1L, outcome, null, null)); - assertThat(tasks).allSatisfy(value -> { - assertThat(value.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); - assertThat(value.getAttemptCount()).isEqualTo(1); - }); + assertThat(tasks.get(0).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(1).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(2).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 15)); + assertThat(tasks.get(4).getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(tasks.get(3).getAttemptCount()).isEqualTo(1); + assertThat(tasks).filteredOn(value -> value != tasks.get(3)) + .allSatisfy(value -> assertThat(value.getAttemptCount()).isZero()); assertThat(tasks.get(0).getStatus()).isEqualTo("READY"); assertThat(tasks.get(1).getStatus()).isEqualTo("READY"); assertThat(tasks.get(2).getStatus()).isEqualTo("MANUAL_REQUIRED"); @@ -242,6 +246,96 @@ class CarrierScheduleQueryServiceTest { assertThat(tasks.get(4).getStatus()).isEqualTo("MANUAL_REQUIRED"); } + @Test + void retryableFailureUsesFifteenThenThirtyMinutesAndStopsAfterThreeCallsInTheSlot() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:00:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 15)); + assertThat(task.getAttemptCount()).isEqualTo(1); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:15:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 9, 45)); + assertThat(task.getAttemptCount()).isEqualTo(2); + + set("clock", Clock.fixed(Instant.parse("2026-08-10T01:45:00Z"), ZoneOffset.UTC)); + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "TIMEOUT", "timeout"); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + + ArgumentCaptor attempts = + ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class); + verify(repository, org.mockito.Mockito.times(3)).insert(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(CarrierScheduleQueryAttemptEntity::getWindowAttemptNo) + .containsExactly(1, 2, 3); + } + + @Test + void retryThatWouldReachTheNextSlotWaitsForTheSlotInstead() throws Exception { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 12, 20)); + when(repository.lock(1L)).thenReturn(task); when(repository.find(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + set("clock", Clock.fixed(Instant.parse("2026-08-10T04:20:00Z"), ZoneOffset.UTC)); + + service.completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable"); + + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getAttemptCount()).isZero(); + } + + @Test + void missedNormalSlotIsDeferredWithoutCallingTheProviderOrWritingAnAttempt() { + CarrierScheduleQueryEntity task = task(0, 0); + task.setStatus("READY"); task.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + task.setLeaseOwner("worker"); task.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 10, 5)); + when(repository.lock(1L)).thenReturn(task); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + boolean deferred = service.deferMissedNormalSlot(1L, LocalDateTime.of(2026, 8, 10, 10, 0)); + + assertThat(deferred).isTrue(); + assertThat(task.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(task.getLeaseOwner()).isNull(); assertThat(task.getLeaseUntil()).isNull(); + verify(repository, never()).insert(any(CarrierScheduleQueryAttemptEntity.class)); + } + + @Test + void staleRetryBecomesANewFirstAttemptOnlyDuringTheNextFixedSlotMinute() { + CarrierScheduleQueryEntity atSlot = task(2, 0); + atSlot.setStatus("READY"); atSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + atSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); atSlot.setConsecutiveFailures(2); + atSlot.setLeaseOwner("worker"); atSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 12, 35)); + when(repository.lock(1L)).thenReturn(atSlot); + when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1); + + boolean executeCurrentSlot = service.deferMissedNormalSlot(1L, + LocalDateTime.of(2026, 8, 10, 12, 30, 20)); + + assertThat(executeCurrentSlot).isFalse(); + assertThat(atSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); + assertThat(atSlot.getAttemptCount()).isZero(); + assertThat(atSlot.getLeaseOwner()).isEqualTo("worker"); + + CarrierScheduleQueryEntity outsideSlot = task(2, 0); + outsideSlot.setStatus("READY"); outsideSlot.setWindowStartedAt(LocalDateTime.of(2026, 8, 10, 9, 0)); + outsideSlot.setNextAllowedAt(LocalDateTime.of(2026, 8, 10, 9, 45)); + outsideSlot.setLeaseOwner("worker"); outsideSlot.setLeaseUntil(LocalDateTime.of(2026, 8, 10, 13, 5)); + when(repository.lock(2L)).thenReturn(outsideSlot); + + boolean deferred = service.deferMissedNormalSlot(2L, LocalDateTime.of(2026, 8, 10, 13, 0)); + + assertThat(deferred).isTrue(); + assertThat(outsideSlot.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 16, 0)); + assertThat(outsideSlot.getAttemptCount()).isZero(); + assertThat(outsideSlot.getLeaseOwner()).isNull(); + } + @Test void matsonIsAdvertisedAsAutomatedOnlyWhenDeploymentEnablesIt() { CarrierScheduleAdapterRegistry registry = new CarrierScheduleAdapterRegistry(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java index 31e276927..0292a766a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlotsTest.java @@ -16,6 +16,10 @@ class CarrierScheduleQuerySlotsTest { assertThat(CarrierScheduleQuerySlots.nextSlot(at(15, 59, 59))).isEqualTo(at(16, 0, 0)); assertThat(CarrierScheduleQuerySlots.nextSlot(at(16, 0, 0))) .isEqualTo(LocalDateTime.of(2026, 8, 22, 9, 0)); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 0, 59))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(9, 1, 0))).isTrue(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 0, 0), 0, at(12, 30, 30))).isFalse(); + assertThat(CarrierScheduleQuerySlots.isMissedNormalSlot(at(9, 15, 0), 1, at(10, 0, 0))).isFalse(); } private static LocalDateTime at(int hour, int minute, int second) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index 5e0c86875..b29dba168 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -84,6 +84,8 @@ class Phase3FinalUnifiedSqlContractTest { .contains("platform:firstleg:outbound-dispatch:%") .contains("platform:firstleg:customs:%") .contains("platform:firstleg:port-schedule:%") + .contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3") + .contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36") .contains("platform:firstleg:pda:%") .contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')") .contains("角色授权未完整写入"); @@ -210,6 +212,10 @@ class Phase3FinalUnifiedSqlContractTest { assertThat(sql) .doesNotContain("\n ADD COLUMN IF NOT EXISTS") .doesNotContain("\n DROP CHECK IF EXISTS") + .doesNotContain("\n+--") + .doesNotContain("\n---") + .doesNotContain("\n>>>") + .doesNotContain("\n<<<") .contains("CREATE PROCEDURE phase3_pda_add_column") .contains("CREATE PROCEDURE phase3_pda_replace_operation_check"); } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java index ebcca60ee..f92681d06 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/EvergreenCarrierScheduleProviderAdapter.java @@ -15,6 +15,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; @@ -32,7 +33,10 @@ import java.util.regex.Pattern; public class EvergreenCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter { private static final String PROVIDER = "EVERGREEN_PUBLIC_HTML"; private static final String LOADED = "LOADED (FCL) ON VESSEL"; - private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("MMM-dd-uuuu", Locale.ENGLISH); + private static final DateTimeFormatter DAY = new DateTimeFormatterBuilder() + .parseCaseInsensitive() + .appendPattern("MMM-dd-uuuu") + .toFormatter(Locale.ENGLISH); private static final Pattern TABLE = Pattern.compile("(?is)]*>(.*?)"); private static final Pattern ROW = Pattern.compile("(?is)]*>(.*?)"); private static final Pattern CELL = Pattern.compile("(?is)]*>(.*?)"); From af9721718431a520c8003f024cf80214554ef9dd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 21 Aug 2026 17:19:49 +0800 Subject: [PATCH 037/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- deploy/scripts/deploy-built-service.sh | 31 +++++++------ lastleg-service/AGENTS.md | 4 +- .../controller/InboundPlanController.java | 4 +- .../InboundPlanPermissionContractTest.java | 12 ++++++ scripts/README.md | 2 +- scripts/agents-index-contract.ps1 | 43 ++++++++++++++++++- scripts/db/incremental.sql | 2 + scripts/overseas-deployment-contract.test.mjs | 4 ++ ...est-environment-pipeline-contract.test.mjs | 19 ++++++++ 10 files changed, 104 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ad6df887..331982177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` | +| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | @@ -75,7 +76,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证部署单元、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 ## 全量 HTTP 验收脚本路由 diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index e41e3719c..d10d16685 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -214,25 +214,28 @@ run_scp() { run_ssh install -d -m 750 "$REMOTE_DIR" SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" -REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" +REMOTE_ENV_NAME=".env.$PROJECT_NAME" +REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" +REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp" +REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp" run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" -run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' +run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" <<'REMOTE_PREPARE' set -eu -remote_dir="$1" -env_tmp="$2" -compose_tmp="$3" +env_tmp="$1" +compose_tmp="$2" +env_target="$3" +compose_target="$4" chmod 600 "$env_tmp" chmod 644 "$compose_tmp" -mv -f "$env_tmp" "$remote_dir/.env" -mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" +mv -f "$env_tmp" "$env_target" +mv -f "$compose_tmp" "$compose_target" REMOTE_PREPARE echo "传输镜像到 $SSH_TARGET: $IMAGE" docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load' -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" <<'REMOTE_DEPLOY' +run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" <<'REMOTE_DEPLOY' set -eu remote_dir="$1" deploy_env="$2" @@ -240,18 +243,20 @@ image_repository="$3" image_tag="$4" service="$5" project_name="$6" +env_file="$7" +compose_file="$8" cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" docker compose \ --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ + --env-file "$env_file" \ + -f "$compose_file" \ up -d --remove-orphans --pull never "$service" docker compose \ --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ + --env-file "$env_file" \ + -f "$compose_file" \ ps "$service" REMOTE_DEPLOY diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index a4e5e1f4b..e16e1f071 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -10,7 +10,8 @@ | 功能 | 入口 | |---|---| -| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | +| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端按冻结字段校验后建单 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | | 订单查询与更新 | `service/LastlegOrderService.java` | @@ -24,6 +25,7 @@ | PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | | 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index f294b6fc7..3bdb1f82d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -33,7 +33,7 @@ public class InboundPlanController { /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @@ -43,7 +43,7 @@ public class InboundPlanController { } @PostMapping("/convert") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") /** 将入仓单转换为入仓计划。 */ @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index d73111a9b..8336749d1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class InboundPlanPermissionContractTest { private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + private static final String CREATE = "platform:overseas-warehouse:inbound-order:create"; private static final String FBA_APPOINTMENT = "platform:overseas-warehouse:inbound-order:fba-appointment"; @@ -28,6 +29,17 @@ class InboundPlanPermissionContractTest { } } + @Test + void conversionUsesCanonicalInboundOrderCreateAuthority() { + Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals("convert")) + .findFirst() + .orElseThrow(); + + assertEquals("hasAuthority('" + CREATE + "')", method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + CREATE, method.getAnnotation(Operation.class).description()); + } + @Test void fbaAppointmentWritesUseDedicatedAuthority() { Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) diff --git a/scripts/README.md b/scripts/README.md index 06dc4f67f..ee7075b31 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对部署单元、业务模块、PDA 注册页面、Maven 模块、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、PDA 注册页面、Maven 模块、关键入口与排除目录 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 70e839089..cc5e214f9 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -36,6 +36,27 @@ function Assert-Path([string]$relativePath) { } } +function Assert-MarkdownTableUnique([string]$path, [string]$heading, [int]$keyColumn, [string]$scope) { + $script:checked++ + $lines = Get-Content -LiteralPath $path + $inside = $false + $keys = [System.Collections.Generic.List[string]]::new() + foreach ($line in $lines) { + if ($line -eq $heading) { + $inside = $true + continue + } + if ($inside -and $line.StartsWith('## ')) { break } + if (-not $inside -or -not $line.StartsWith('|')) { continue } + $cells = @($line.Trim('|').Split('|') | ForEach-Object { $_.Trim() }) + if ($keyColumn -ge $cells.Count -or $cells[$keyColumn] -match '^:?-{3,}:?$') { continue } + $keys.Add($cells[$keyColumn]) + } + $keys | Group-Object | Where-Object Count -gt 1 | ForEach-Object { + Add-Failure "$scope 存在重复索引键: $($_.Name)($($_.Count) 次)" + } +} + $rootAgentsPath = Join-Path $workspaceRoot 'AGENTS.md' $adminAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/AGENTS.md' $clientAgentsPath = Join-Path $workspaceRoot 'apps/client-web/AGENTS.md' @@ -55,6 +76,10 @@ Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory | Sort-Object Name | ForEach-Object { Assert-Contains $rootAgents "apps/$($_.Name)/" '根 AGENTS.md' } Assert-Contains $rootAgents 'baiyue-firstleg/' '根 AGENTS.md' +Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 0 '根项目表' +Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 1 '根项目目录' +Assert-MarkdownTableUnique $rootAgentsPath '## 业务快速路由表' 0 '根业务路由表' +Assert-MarkdownTableUnique $rootAgentsPath '## 原型入口索引' 0 '根原型表' # 前端当前全部业务模块必须能从应用级索引命中。 $applications = @( @@ -95,6 +120,9 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/pallets.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/dispatches.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchesView.vue', + 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', 'apps/pda-uniapp/pages/tasks/tasks.vue', @@ -111,8 +139,19 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleQuerySlots.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java', + 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java', + 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java', + 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', - 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java' + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', + 'docs/四期/船节点标准化契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } @@ -164,5 +203,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖部署单元、前端模块、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 2011ee141..678e02e2e 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3442,6 +3442,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='remark' AND COALESCE(column_comment,'') <> '备注'), 'ALTER TABLE wh_pallet MODIFY COLUMN remark VARCHAR(500) DEFAULT NULL COMMENT ''备注''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code' AND COALESCE(column_comment,'') <> '所属FBA仓编码'), 'ALTER TABLE wh_pallet MODIFY COLUMN fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT ''所属FBA仓编码''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_by' AND COALESCE(column_comment,'') <> '创建人ID'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_by BIGINT UNSIGNED DEFAULT NULL COMMENT ''创建人ID''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index e37d7e708..575df9e41 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -70,6 +70,10 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(scripts, /env_file_value REGION_CODE/) assert.match(scripts, /EVENT_BRIDGE_REGION_OVERRIDE/) assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) + assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) + assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) + assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) + assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) for (const key of [ 'NACOS_SERVER_ADDR', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index e76beaf5b..e3463170f 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -39,6 +39,25 @@ test('Java builds use the shared builder pool and deploy on the target environme assert.match(directDeployScript, /--pull never/) }) +test('parallel remote deployments keep env and compose assets isolated by project', () => { + assert.match(directDeployScript, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) + assert.match(directDeployScript, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) + assert.match(directDeployScript, /REMOTE_ENV_TMP="\$REMOTE_DIR\/\$REMOTE_ENV_NAME\.\$SUFFIX\.tmp"/) + assert.match(directDeployScript, /REMOTE_COMPOSE_TMP="\$REMOTE_DIR\/\$REMOTE_COMPOSE_NAME\.\$SUFFIX\.tmp"/) + assert.match(directDeployScript, /env_target="\$3"/) + assert.match(directDeployScript, /compose_target="\$4"/) + assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/) + assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_target"/) + assert.match(directDeployScript, /env_file="\$7"/) + assert.match(directDeployScript, /compose_file="\$8"/) + assert.match(directDeployScript, /--env-file "\$env_file"/) + assert.match(directDeployScript, /-f "\$compose_file"/) + assert.doesNotMatch(directDeployScript, /mv -f "\$env_tmp" "\$remote_dir\/\.env"/) + assert.doesNotMatch(directDeployScript, /mv -f "\$compose_tmp" "\$remote_dir\/docker-compose\.yml"/) + assert.doesNotMatch(directDeployScript, /--env-file \.env/) + assert.doesNotMatch(directDeployScript, /-f docker-compose\.yml/) +}) + test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { assert.match(pipeline, /MAVEN_SETTINGS = 'deploy\/maven\/settings-ci\.xml'/) assert.match(ciMavenSettings, /https:\/\/maven\.aliyun\.com\/repository\/public/) From bbd303cd65b751e87ea66c166124c9c732735377 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 21 Aug 2026 17:21:26 +0800 Subject: [PATCH 038/231] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 35 +++ lastleg-service/AGENTS.md | 2 +- .../MerchantCargoDiscardController.java | 39 +++ .../controller/DispatchQueryController.java | 4 +- .../controller/SmartDispatchController.java | 63 ++++- .../dispatch/dto/DispatchDraftResponse.java | 4 + .../dispatch/dto/DispatchRequests.java | 19 +- .../dispatch/entity/DispatchDraft.java | 23 ++ .../dispatch/entity/DispatchPalletLock.java | 8 + .../mapper/DispatchCandidateMapper.java | 61 +++-- .../dispatch/mapper/DispatchDraftMapper.java | 18 ++ .../dispatch/mapper/DispatchOrderMapper.java | 5 + .../mapper/DispatchPalletLockMapper.java | 6 + .../model/DispatchPalletCandidate.java | 28 ++- .../dispatch/service/DispatchService.java | 235 +++++++++++++++--- .../MerchantRelabelApplicationController.java | 35 +++ .../MerchantWarehouseTransferController.java | 47 ++++ .../WarehouseTransferController.java | 7 + .../dto/WarehouseTransferRequests.java | 9 + .../dto/WarehouseTransferResponses.java | 4 + .../service/WarehouseTransferService.java | 28 +++ .../src/main/resources/db/lastleg.sql | 14 +- .../openapi/overseas-business-v1.yaml | 53 +++- .../MerchantCargoDiscardControllerTest.java | 48 ++++ .../DispatchCandidateMapperContractTest.java | 48 ++++ ...atchPalletLockPersistenceContractTest.java | 38 +++ .../dispatch/DispatchSchemaContractTest.java | 3 + .../SmartDispatchOpenApiContractTest.java | 7 +- .../DispatchQueryControllerTest.java | 7 +- .../SmartDispatchControllerTest.java | 9 + .../dispatch/service/DispatchServiceTest.java | 125 +++++++++- ...chantRelabelApplicationControllerTest.java | 46 ++++ ...rchantWarehouseTransferControllerTest.java | 49 ++++ .../PlatformWarehouseResourceController.java | 3 +- .../src/main/resources/application.yml | 6 +- ...rehouseResourcePermissionContractTest.java | 9 +- 36 files changed, 1045 insertions(+), 100 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPalletLockPersistenceContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 8afc2bb8f..a8dd32542 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16194,6 +16194,41 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。 +CREATE DATABASE IF NOT EXISTS baiyue_lastleg + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( + id BIGINT NOT NULL COMMENT '主键ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_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_dispatch_active_pallet (customer_id,active_key), + KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), + KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) +) ENGINE=InnoDB COMMENT='派车托盘排他锁'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空''')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e16e1f071..57ea56cf3 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -41,7 +41,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 -- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。四个排车平台入口均不接收 `customerId`:排车列表/详情从既有排车数据解析范围,智能排车候选根据已选入库计划 ID、预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 +- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java new file mode 100644 index 000000000..fc5577461 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/cargo-discards") +public class MerchantCargoDiscardController { + @Resource private CargoDiscardService service; + @GetMapping("/inventory-candidates") + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); } + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); } + @GetMapping + public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, + @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + @GetMapping("/{discardNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); } + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + return String.valueOf(user.tenantId()); + } + private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index cc8677d2b..c31d84a80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -35,7 +35,7 @@ public class DispatchQueryController { @AuthenticationPrincipal AuthenticatedUser user, @Valid DispatchRequests.Query query) { requirePlatform(user); - return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query)); + return ApiResponse.success(service.platformPage(query)); } @GetMapping("/{dispatchNo}") @@ -45,7 +45,7 @@ public class DispatchQueryController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo)); + return ApiResponse.success(service.platformDetail(dispatchNo)); } private void requirePlatform(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index fe30d351a..7e82f21b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -20,6 +20,7 @@ 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.RequestParam; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -38,13 +39,30 @@ public class SmartDispatchController { description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam List planIds, + @RequestParam(required = false) List planIds, @RequestParam(required = false) String orderNo, @RequestParam(required = false) String fbaWarehouseCode, - @RequestParam(required = false) String sourceStage) { + @RequestParam(required = false) String sourceStage, + @RequestParam(required = false) String draftToken) { requirePlatform(user); + if (planIds == null || planIds.isEmpty()) { + return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); + } return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, - fbaWarehouseCode, sourceStage)); + fbaWarehouseCode, sourceStage, draftToken)); + } + + @GetMapping("/drafts") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user))); + } + + @PostMapping("/drafts/snapshot") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftSnapshot request) { + requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null); } @PostMapping("/pre-arrange") @@ -60,6 +78,45 @@ public class SmartDispatchController { return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); } + @PostMapping("/draft-locks/lock") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft") + public ApiResponse lockDraftPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftPallet request) { + requirePlatform(user); + String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + service.lockDraftPallet(customerId, operatorId(user), request); + return ApiResponse.success(null); + } + + @PostMapping("/draft-locks/unlock") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft") + public ApiResponse unlockDraftPallet( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftPallet request) { + requirePlatform(user); + String customerId = service.resolveCustomerScopeForPallets(List.of(request.planPalletId())); + service.unlockDraftPallet(customerId, operatorId(user), request); + return ApiResponse.success(null); + } + + @PostMapping("/draft-locks/release") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft") + public ApiResponse releaseDraft( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.DraftRelease request) { + requirePlatform(user); + service.releaseDraft(operatorId(user), request.draftToken()); + return ApiResponse.success(null); + } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java new file mode 100644 index 000000000..75949095b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.dispatch.dto; + +public record DispatchDraftResponse(String draftToken, String planIdsJson, String containersJson) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 370d81171..d0176a129 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.dispatch.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -21,7 +22,23 @@ public final class DispatchRequests { public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds, @Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, - @Size(max = 32) String vehicleAppointmentStatus) { + @Size(max = 32) String vehicleAppointmentStatus, + @Size(max = 64) String draftToken) { + public PreArrange(List planPalletIds, String route, String fbaAppointmentNo, + String fbaAppointmentStatus, String vehicleAppointmentStatus) { + this(planPalletIds, route, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null); + } + } + + public record DraftPallet(@NotNull Long planPalletId, @NotBlank @Size(max = 64) String draftToken) { + } + + public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { + } + + public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken, + @NotEmpty @Size(max = 500) List<@NotNull Long> planIds, + @NotBlank @Size(max = 20000) String containersJson) { } public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java new file mode 100644 index 000000000..7e569b07f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class DispatchDraft { + @TableId(type = IdType.INPUT) + private Long id; + private String draftToken; + private String customerId; + private String operatorId; + private String planIdsJson; + private String containersJson; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index 386d77e17..4869e059b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -1,7 +1,9 @@ package com.baiyue.lastleg.dispatch.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -16,13 +18,19 @@ public class DispatchPalletLock { private String customerId; private String sourcePalletKey; /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeKey; private Long planId; private Long planPalletId; private Long warehousePalletId; private Long dispatchOrderId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String draftToken; + private String operatorId; private String status; private LocalDateTime lockedAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private LocalDateTime expiresAt; private LocalDateTime releasedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e25b11ff0..e5e4f5875 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,12 +9,6 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { - @Select("SELECT customer_id FROM (" + - "SELECT customer_id FROM ll_inbound_plan UNION SELECT customer_id FROM ll_dispatch_order" + - ") dispatch_customers WHERE customer_id IS NOT NULL AND customer_id != '' " + - "ORDER BY customer_id LIMIT 2") - List findCustomerScopes(); - @Select("')), + }, token) + context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + const photos = [] + for (const role of PHOTO_ROLES) { + const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, + }, token) + const fileId = id(response?.body?.data?.fileId) + context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId), + `role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS) + if (!longId(fileId)) return null + photos.push({ role, fileId }) } + return photos } -async function operateLegacyAllocations(context, customerId, planNo, task) { - for (const [index, allocation] of (task.allocations || []).entries()) { - const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0) - if (remaining <= 0) continue - await overseas(context, 'POST', `清点入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), { - packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - await overseas(context, 'POST', `实收入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), { - packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) +function skuFixtures(context) { + const boxes = context.createdResources?.orders?.boxTypes + const orchestration = context.createdResources?.orchestrationFixtures || {} + const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, + orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) + return Array.isArray(boxes) + ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + : [] +} + +async function receiveFba(context, token, inbound, pallet) { + const skus = skuFixtures(context) + if (!skus.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') + return false } + let accepted = 0 + for (const [index, skuCode] of skus.entries()) { + const resolvedResponse = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) + const resolvable = success(resolvedResponse) || notFound(resolvedResponse) + context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) + const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1 + const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } + const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict) + } + if (accepted <= 0) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) + return false + } + return true +} + +async function receiveOverseas(context, token, inbound, pallet) { + const cartonMark = context.createdResources?.inboundMeasurement?.boxCode + if (!nonBlank(cartonMark)) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') + return false + } + const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') } + const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict) + return true } export async function run(context) { - context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null, - planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true } - const source = await findUnplannedOrder(context) - if (!source) { - context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', - '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null, + palletIds: [], locationIds: [], finalStatus: null, + cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + } + const token = await loginPda(context) + if (!token) return + if (context.overseasToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) + context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const noCurrent = notFound(before) + context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), + `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) + if (success(before)) { + blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before) return } - const { customerId, order } = source - const warehouse = await createWarehouse(context, customerId) - if (!longId(warehouse.warehouseId)) return - Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId }) - - const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓', - query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), { - warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode, - warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`, - }) - if (!success(transferred.response)) return - const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', { - orderId: id(order.id), customerId, - }) - const plan = converted.data - const planNo = plan?.planNo || '' - context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response, - Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0, - `planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS) - if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return - context.createdResources.phase4OverseasPda.planNo = planNo - - for (const [index, planPallet] of plan.pallets.entries()) { - const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode) - if (!longId(resource.locationId) || !longId(resource.palletId)) return - const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), { - warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId, - idempotencyKey: key(context, 'assign', String(index + 1)), - }) - if (!success(assigned.response)) return + const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates') + if (!Array.isArray(candidates.data) || candidates.data.length === 0) { + blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) + return } - - const pushed = await overseas(context, 'POST', '推送PDA入仓任务', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), { - idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - const task = pushed.data - context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response, - longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length - && Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons), - `taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS) - if (!longId(task?.taskId)) return - context.createdResources.phase4OverseasPda.taskId = id(task.taskId) - - if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task) - else await operateLegacyAllocations(context, customerId, planNo, task) - - const photo = await context.requestOverseasMultipart('POST', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), { - name: `${context.marker}-unload.png`, type: 'image/png', - content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')), - }) - context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS) - const fileId = id(photo?.body?.data?.fileId) - context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS) - if (!longId(fileId)) return - const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), { - fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response, - bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt), - `fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS) - - const completed = await overseas(context, 'POST', '完成PDA入仓并写库存', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId })) - context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response, - completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt), - `status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS) + const preferredContainer = context.createdResources?.phase3Loading?.containerNo + const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0] + const containerNo = id(candidate?.containerNo) + context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, + nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, + `containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS) + if (!containerNo) return + context.createdResources.phase4OverseasPda.containerNo = containerNo + const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`) + if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return + const prepareBody = { idempotencyKey: key(context, 'prepare') } + const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + if (!success(prepared.response)) return + const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, + success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + const other = candidates.data.find((item) => item.containerNo !== containerNo) + if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) + else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') + const photos = await uploadPhotos(context, token) + if (!photos) return + const signBody = { photos, idempotencyKey: key(context, 'sign') } + const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + if (!success(signed.response)) return + const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response, + success(signReplay.response) && signReplay.data?.status === 'SIGNED' + && PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS) + await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign', + { ...signBody, photos: [...photos].reverse() }, conflict) + const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(current.response)) return + const pallets = new Map() + for (const [index, inbound] of current.data.inbounds.entries()) { + const pallet = await createPallet(context, inbound, index) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, + `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + continue + } + pallets.set(inbound.inboundCode, pallet) + context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) + const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, + '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) + context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, + success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) + } + const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') + const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS') + if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstFba = fbaInbounds[0] + await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), + }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) + for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) + } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { + const pallet = pallets.get(overseasInbound.inboundCode) + await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), + }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) + await receiveOverseas(context, token, overseasInbound, pallet) + } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { + blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) + return + } + const completeBody = { idempotencyKey: key(context, 'complete') } + const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + if (!success(completed.response)) return + const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) + const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null - - const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态', - query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId })) - context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED', - `planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS) - const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存', - query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 })) - context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response, - records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId), - `balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS) } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index aefb917c2..f4e1db7f6 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { parseArguments } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') @@ -33,3 +33,38 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) }) + +test('entry has no built-in environment address or password and requires explicit execution options', () => { + assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/) + assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), + /缺少必填执行参数/) + const options = parseArguments([ + '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + ]) + assert.equal(validateExecutionOptions(options), options) +}) + +test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') + assert.equal(run.steps.at(-1)?.passed, false) + assert.equal(run.steps.at(-1)?.deploymentBlocker, true) + const stepCount = run.steps.length + run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device') + run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party') + assert.equal(run.steps.length, stepCount) +}) + +test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) + } + assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /readyForPhase4Pda === true/) +}) From 5edc2fc1bd600c7e627c0dc55f494baf0864d5b0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:14:39 +0800 Subject: [PATCH 120/231] fix(lastleg): restore overseas receiving build --- .../pda/service/ReceivingContainerService.java | 2 +- .../pda/ReceivingContainerContractTest.java | 7 ++++--- .../service/InboundStockReceiptServiceTest.java | 2 +- .../ReceivingContainerServiceBehaviorTest.java | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) 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 7262e4677..4c6682853 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 @@ -451,7 +451,7 @@ public class ReceivingContainerService { .map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet()); if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once"); Set fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId) - .boxed().collect(Collectors.toSet()); + .collect(Collectors.toSet()); if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) { throw conflict("Unload photo file IDs must be positive and unique"); } 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 5736a0949..d49cf48e4 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 @@ -65,8 +65,9 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); - assertTrue(schema.contains("sign_photos json")); - assertFalse(schema.contains("photo_file_ids json")); + String receivingSession = tableBlock(schema, "ll_receiving_container_session"); + assertTrue(receivingSession.contains("sign_photos json")); + assertFalse(receivingSession.contains("photo_file_ids json")); assertTrue(unified.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(unified.contains("column_name='sign_photos'")); assertTrue(schema.contains("wait_sign、signed、receiving、completed")); @@ -93,7 +94,7 @@ class ReceivingContainerContractTest { assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); - assertTrue(receivingPaths.contains("LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN")); + assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertFalse(receivingPaths.contains("photoFileIds")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 3f1f93bb2..d856e2e58 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -134,7 +134,7 @@ class InboundStockReceiptServiceTest { ArgumentCaptor requests = ArgumentCaptor.forClass(InventoryCommandRequests.StockIn.class); verify(inventory, times(2)).stockIn(eq("100"), eq("operator"), requests.capture()); assertEquals(java.util.Set.of("SKU-1:2001", "SKU-2:2002"), requests.getAllValues().stream() - .map(value -> value.skuCode() + ":" + value.warehousePalletId()).collect(java.util.stream.Collectors.toSet())); + .map(value -> value.skuCode() + ":" + value.palletId()).collect(java.util.stream.Collectors.toSet())); } @Test 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 0ebcaab05..498415847 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 @@ -197,6 +197,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void localFailureAfterRemoteReservationTriggersReleaseCompensation() { stubCurrent("FBA", "CONT-A"); + stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); when(pallets.selectList(any())).thenReturn(List.of(planPallet())); when(workflow.assignResolvedPallet(any(), any(), any(), any())).thenReturn(55L); @@ -244,6 +245,19 @@ class ReceivingContainerServiceBehaviorTest { verifyNoInteractions(unloadPhotos); } + @Test + void signRejectsDuplicateFileIdsBeforeFileValidation() { + when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), + new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files"); + + assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(unloadPhotos); + } + @Test void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); From 005c3f54e22e85512604eda8da40074c33ff5b73 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 20:14:40 +0800 Subject: [PATCH 121/231] test: align full acceptance contracts --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 3 +-- .../customer-system-contract.test.mjs | 2 +- .../domestic-container-contract.test.mjs | 3 ++- .../finance-contract.test.mjs | 3 ++- .../finance-extended-fees-contract.test.mjs | 5 ++-- .../initialization-baseline-contract.test.mjs | 23 ++++++++++--------- .../manual-state-matrix-contract.test.mjs | 2 +- ...nt-qualification-fixture-contract.test.mjs | 3 ++- ...e3-loading-dynamic-final-contract.test.mjs | 2 +- 10 files changed, 26 insertions(+), 22 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0946cd9df..b81a56b25 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -9,7 +9,7 @@ - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ccc7e9bf0..f29c7ed49 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -244,7 +244,7 @@ EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; -- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 --- 初始密码统一为 ChangeMe123!;只保存 BCrypt 哈希,不写入明文。 +-- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, failed_login_count, token_version, password_change_required, default_tenant_id, @@ -255,7 +255,6 @@ INSERT INTO auth_account ( (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) ON DUPLICATE KEY UPDATE - password_hash = VALUES(password_hash), display_name = VALUES(display_name), account_status = 'ACTIVE', failed_login_count = 0, diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index f1d57e6a2..64527c588 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -58,7 +58,7 @@ test('客户详情经营统计模块登记三项独立正式接口与精度字 test('后端统计契约测试覆盖客户详情已确认边界口径', () => { for (const marker of ["order_role = 'master'", 'submitted_at is not null', 'monthstart', 'nextmonthstart', 'doesNotContain\\\("status ="\\\)']) assert.match(orderStatisticsTest, new RegExp(marker, 'i')) for (const marker of ['VOIDED', 'totalReceivable', 'receivedAmount', 'outstandingReceivable']) assert.match(financeStatisticsTest, new RegExp(marker, 'i')) - for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'FAILED', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i')) + for (const marker of ['actual_sailing_at', 'actual_warehouse_arrival_at', 'CANCELLED', 'NOT EXISTS', '20\\*=1', '40\\*=2', '45\\*=2.25', '2.25', 'unknown']) assert.match(transitStatisticsTest, new RegExp(marker, 'i')) assert.match(orderStatisticsTest, /tenant_id|tenantSubmittedStatistics/) assert.match(financeStatisticsTest, /customerId|customer_id/) assert.match(transitStatisticsTest, /customerId|customer_id/) diff --git a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs index 33561fdcc..510f78a85 100644 --- a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs +++ b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs @@ -16,7 +16,8 @@ test('国内整柜模块仅由总编排注入 context 后执行', () => { }) test('正式全量验收默认注册国内整柜模块', () => { - assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s) + assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*['"]domestic-container['"]/s) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('覆盖国内仓、待单、完成入库、部分和全部出库及列表详情流水', () => { diff --git a/scripts/admin-test-env-modules/finance-contract.test.mjs b/scripts/admin-test-env-modules/finance-contract.test.mjs index 29d60e523..8580fde43 100644 --- a/scripts/admin-test-env-modules/finance-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-contract.test.mjs @@ -9,7 +9,8 @@ const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-accepta const finance = fs.readFileSync(path.join(here, 'finance.mjs'), 'utf8') test('正式全量验收默认包含 Finance 模块', () => { - assert.match(acceptance, /DEFAULT_MODULES\s*=\s*\[[\s\S]*['"]finance['"]/) + assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[\s\S]*['"]finance['"]/) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('验收请求器支持并透传额外请求头', () => { diff --git a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs index f07805750..6eb694b53 100644 --- a/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-extended-fees-contract.test.mjs @@ -46,7 +46,8 @@ test('writes carry marker idempotency and include replay and stale-version confl assert.match(source, /错误 sourceVersion 调整产生并发版本冲突/) }) -test('uses existing ACTIVE Warehouse supplier and never creates one', () => { - assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE&category=BOOKING/) +test('uses the fee item supplier category to select an existing ACTIVE supplier and never creates one', () => { + assert.match(source, /\/api\/platform\/v1\/suppliers\?page=1&size=200&status=ACTIVE\$\{suffix\}/) + assert.match(source, /const supplier = await loadActiveSupplier\(context, payableItem\?\.supplierCategory \|\| null\)/) assert.doesNotMatch(source, /\/api\/platform\/v1\/partners/) }) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 2b30d9a18..746730c2a 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -12,6 +12,10 @@ test('统一初始化 SQL 保留最小登录与客户主体', () => { assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) + const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] + assert.ok(accountSeed, 'missing auth account seed') + assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, + 're-running the unified SQL must not reset an existing account password') }) test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { @@ -34,25 +38,22 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/) }) -test('三期权限迁移允许脚本历史版本写入的旧权限并在随后收敛', () => { +test('三期权限迁移删除退役权限并只保留 canonical 权限', () => { for (const source of [sql, oceanBookingSchema]) { - assert.match(source, /id = 5102141[\s\S]*?ocean-booking:view/) - assert.match(source, /id = 5102147[\s\S]*?ocean-booking:cancel[\s\S]*?ocean-booking:edit/) - assert.match(source, /id = 5102148[\s\S]*?ocean-booking:change[\s\S]*?ocean-booking:delete/) - assert.match(source, /SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1/) - assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141/) - assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102143,5102144,5102145\)/) + assert.match(source, /DELETE FROM iam_permission WHERE id IN \(5102141,5102143,5102144,5102145\)/) + assert.match(source, /5102147[\s\S]*?platform:firstleg:ocean-booking:edit/) + assert.match(source, /5102148[\s\S]*?platform:firstleg:ocean-booking:delete/) + assert.doesNotMatch(source, /platform:firstleg:ocean-booking:(view|cancel|change)/) assert.ok( - source.indexOf('DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145)') < + source.indexOf('DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145)') < source.indexOf('(5102147,0,\'PLATFORM\',\'BUTTON\',5102140,\'platform:firstleg:ocean-booking:edit\''), 'legacy ocean-booking edit permission must be removed before assigning the code to 5102147', ) } for (const source of [sql, truckAppointmentSchema]) { - assert.match(source, /id = 5102151[\s\S]*?truck-appointment:view/) - assert.match(source, /SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1/) - assert.match(source, /UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151/) + assert.match(source, /DELETE FROM iam_permission WHERE id=5102151/) + assert.doesNotMatch(source, /platform:firstleg:truck-appointment:view[',]/) } assert.match(sql, /status='ACTIVE' AND deleted=0[\s\S]*?id BETWEEN 5102140 AND 5102149/) diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 096592364..5f7455aa8 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -35,9 +35,9 @@ test('focused splitting and manual groups resolve producers before consumers wit 'merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading', - 'phase3-order-splitting', 'ocean-bookings', 'phase3-loading-finalize', + 'phase3-order-splitting', 'phase3-customs', 'manual-state-matrix', ] diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 4da2e138d..8d080ea93 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -6,9 +6,10 @@ const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', impo const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') test('默认全量验收先准备已审核商户再运行订单链路', () => { - const modules = acceptance.match(/const DEFAULT_MODULES = \[([^\n]+)\]/)?.[1] || '' + const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' assert.match(modules, /^'merchant-qualification-fixture', 'customers'/) assert.ok(modules.indexOf("'merchant-qualification-fixture'") < modules.indexOf("'orders'")) + assert.match(acceptance, /DEFAULT_MODULES\s*=\s*normalizeModuleOrder\(CONFIGURED_MODULES\)/) }) test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => { diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index c48a31309..8e604f641 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -41,7 +41,7 @@ test('OpenAPI freezes LOADING incremental plan capabilities and optimistic versi }) test('default fixture publishes a second marker-owned inbound for the same-plan increment', () => { - assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD-INC`/) + assert.match(entry, /const incrementalMarker = `\$\{originalMarker\}-LOAD\$\{suffix\}-INC`/) assert.match(entry, /orders-phase3-loading-incremental-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/) assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/) From 3607bebbfd85b05b6e7c9a4d68896f0b61025e7f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:00:22 +0800 Subject: [PATCH 122/231] fix: close phase4 overseas acceptance gaps --- deploy/sql/头程数据库统一增量-MySQL8.sql | 40 +++++- deploy/sql/海外区域统一增量-MySQL8.sql | 64 ++++++++- .../gateway/config/GatewaySecurityConfig.java | 8 +- .../filter/CorrelationIdWebFilter.java | 52 ++++++++ .../GatewaySecurityErrorContractTest.java | 30 +++++ .../main/resources/db/lastleg-permissions.sql | 39 +++++- .../resources/db/phase3-warehouse-roles.sql | 1 + ...erseasPhase4PermissionSqlContractTest.java | 20 +++ .../WarehouseRoleSqlContractTest.java | 1 + lastleg-service/AGENTS.md | 1 + .../ReceivingContainerController.java | 35 ++--- .../ReceivingContainerOperationMapper.java | 7 +- .../ReceivingContainerSessionMapper.java | 21 ++- .../service/ReceivingContainerService.java | 126 +++++++++++------- .../inventory/entity/InventoryLedger.java | 4 + .../entity/OverseasProcurementOrder.java | 22 +-- .../src/main/resources/db/lastleg.sql | 8 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 12 +- .../ReceivingContainerControllerTest.java | 29 ++-- ...ReceivingContainerServiceBehaviorTest.java | 98 ++++++++++---- .../InventorySchemaContractTest.java | 10 +- .../OverseasProcurementServiceTest.java | 14 ++ scripts/admin-test-env-acceptance.mjs | 58 +++++++- scripts/admin-test-env-modules/orders.mjs | 3 +- .../phase3-loading-marker-contract.test.mjs | 15 +++ .../admin-test-env-modules/phase3-loading.mjs | 9 +- .../phase3-warehouse-rbac.mjs | 7 +- ...ase4-cross-region-events-contract.test.mjs | 19 +++ .../phase4-overseas-pda-contract.test.mjs | 48 +++++-- .../phase4-overseas-pda.mjs | 94 ++++++++++--- ...hase4-overseas-warehouse-contract.test.mjs | 18 ++- .../phase4-overseas-warehouse.mjs | 24 +++- .../MerchantWarehouseController.java | 2 +- .../PlatformWarehouseController.java | 3 +- .../warehouse/dto/WarehouseRequests.java | 3 + .../repository/WarehouseRepository.java | 2 + .../MerchantWarehouseControllerTest.java | 36 +++++ .../PlatformWarehouseControllerTest.java | 23 +++- .../repository/WarehouseRepositoryTest.java | 42 ++++++ .../WarehouseApplicationServiceTest.java | 4 +- ...house-public-master-data-contract.test.mjs | 5 +- 42 files changed, 860 insertions(+), 203 deletions(-) create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java create mode 100644 scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f29c7ed49..9e282cfd7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10908,6 +10908,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) @@ -16265,10 +16266,47 @@ INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permissio SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.id BETWEEN 5107000 AND 5107299 + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM baiyue_iam.iam_role_permission role_permission +JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +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 +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) 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 + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 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 + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_overseas_pda_picker_permission(); +DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_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 3c07f7500..c02a02b7f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -387,9 +387,9 @@ SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', - operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', - active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_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 '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), @@ -405,6 +405,31 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_receiving_container_session DROP COLUMN photo_file_ids', 'SELECT 1')); PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; +DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_global_locks_migratable() +BEGIN + IF EXISTS (SELECT operator_key FROM ll_receiving_container_session + WHERE active_operator_key IS NOT NULL GROUP BY operator_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局账号锁迁移失败:同一账号存在多个未完成柜'; + END IF; + IF EXISTS (SELECT UPPER(TRIM(container_no)) FROM ll_receiving_container_session + WHERE active_container_key IS NOT NULL GROUP BY UPPER(TRIM(container_no)) HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局柜号锁迁移失败:同一柜存在多个未完成会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_global_locks_migratable(); +DROP PROCEDURE assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=CONCAT('OPERATOR:',operator_key), + active_container_key=CONCAT('CONTAINER:',UPPER(TRIM(container_no))) +WHERE active_operator_key IS NOT NULL OR active_container_key IS NOT NULL; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + MODIFY COLUMN active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + MODIFY COLUMN active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键'; + CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt ( id BIGINT NOT NULL COMMENT 'FBA SKU实收进度ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID', plan_id BIGINT NOT NULL COMMENT '入仓计划ID', @@ -424,8 +449,39 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; +DROP PROCEDURE IF EXISTS assert_receiving_operation_global_key_migratable; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_operation_global_key_migratable() +BEGIN + IF EXISTS (SELECT operator_key,idempotency_key FROM ll_receiving_container_operation + GROUP BY operator_key,idempotency_key HAVING COUNT(*) > 1) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA全局幂等键迁移失败:同一操作账号存在重复幂等键'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_operation_global_key_migratable(); +DROP PROCEDURE assert_receiving_operation_global_key_migratable; +SET @receiving_operation_global_index = ( + SELECT COUNT(*) FROM ( + SELECT index_name FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' + GROUP BY index_name + HAVING GROUP_CONCAT(column_name ORDER BY seq_in_index)= 'operator_key,idempotency_key' + ) ready_index +); +SET @receiving_operation_named_index = ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_operation' + AND index_name='uk_ll_receiving_operation' +); +SET @ddl = IF(@receiving_operation_global_index=1, 'SELECT 1', + IF(@receiving_operation_named_index>0, + 'ALTER TABLE ll_receiving_container_operation DROP INDEX uk_ll_receiving_operation, ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)', + 'ALTER TABLE ll_receiving_container_operation ADD UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key)')); +PREPARE phase4_receiving_operation_index_stmt FROM @ddl; EXECUTE phase4_receiving_operation_index_stmt; DEALLOCATE PREPARE phase4_receiving_operation_index_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'ALTER TABLE ll_inbound_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); 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 d75af294b..d82f31a65 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 @@ -1,5 +1,6 @@ package com.baiyue.gateway.config; +import com.baiyue.gateway.filter.CorrelationIdWebFilter; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -66,11 +67,16 @@ public class GatewaySecurityConfig { ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + String requestId = CorrelationIdWebFilter.ensureRequestId(exchange); + String traceId = CorrelationIdWebFilter.ensureTraceId(exchange); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.REQUEST_HEADER, requestId); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId); Map body = new LinkedHashMap<>(); body.put("code", code); body.put("message", message); body.put("data", null); - body.put("requestId", exchange.getRequest().getId()); + body.put("requestId", requestId); + body.put("traceId", traceId); body.put("timestamp", Instant.now().toString()); try { byte[] bytes = mapper.writeValueAsBytes(body); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..f25941cec --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -0,0 +1,52 @@ +package com.baiyue.gateway.filter; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** Ensures overseas security failures carry the same correlation contract as normal API responses. */ +@Component +public class CorrelationIdWebFilter implements WebFilter, Ordered { + public static final String REQUEST_HEADER = "X-Request-Id"; + public static final String TRACE_HEADER = "X-Trace-Id"; + private static final String REQUEST_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; + private static final String TRACE_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + exchange.getResponse().getHeaders().set(REQUEST_HEADER, ensureRequestId(exchange)); + exchange.getResponse().getHeaders().set(TRACE_HEADER, ensureTraceId(exchange)); + return chain.filter(exchange); + } + + public static String ensureRequestId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), REQUEST_HEADER)); + } + + public static String ensureTraceId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(TRACE_ATTRIBUTE, + ignored -> accepted(exchange.getRequest().getHeaders(), TRACE_HEADER)); + } + + private static String accepted(HttpHeaders headers, String name) { + String incoming = headers.getFirst(name); + return valid(incoming) ? incoming : UUID.randomUUID().toString(); + } + + private static boolean valid(String value) { + return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128 + && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java new file mode 100644 index 000000000..6b1cea992 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.gateway.config; + +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class GatewaySecurityErrorContractTest { + @Test + void audienceAndAuthenticationFailuresExposeRequestAndTraceCorrelation() throws Exception { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/test") + .header("X-Request-Id", "request-1234567890") + .header("X-Trace-Id", "trace-123456789012") + .build()); + + GatewaySecurityConfig.writeError(exchange, new ObjectMapper(), HttpStatus.UNAUTHORIZED, + "A0401", "未登录或令牌已失效").block(); + + var body = new ObjectMapper().readTree(exchange.getResponse().getBodyAsString().block()); + assertEquals("request-1234567890", body.path("requestId").asText()); + assertEquals("trace-123456789012", body.path("traceId").asText()); + assertEquals(body.path("requestId").asText(), exchange.getResponse().getHeaders().getFirst("X-Request-Id")); + assertEquals(body.path("traceId").asText(), exchange.getResponse().getHeaders().getFirst("X-Trace-Id")); + assertFalse(body.path("timestamp").asText().isBlank()); + } +} diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index f8aebcd23..cf6287b13 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -72,10 +72,47 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.id BETWEEN 5107000 AND 5107299 + AND permission.id BETWEEN 5107000 AND 5107299 AND permission.permission_code<>'lastleg:pda:operate' WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_role role ON role.id=role_permission.role_id AND role.tenant_id=role_permission.tenant_id +JOIN iam_permission permission ON permission.id=role_permission.permission_id AND permission.tenant_id=role_permission.tenant_id +WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER'); + +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 +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code='lastleg:pda:operate' +WHERE role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +DROP PROCEDURE IF EXISTS assert_overseas_pda_picker_permission; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_pda_picker_permission() +BEGIN + IF (SELECT COUNT(*) 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 + WHERE permission.permission_code='lastleg:pda:operate' + AND role.id=4000005 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:WAREHOUSE_PICKER未精确授权'; + END IF; + IF EXISTS (SELECT 1 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 + WHERE permission.permission_code='lastleg:pda:operate' + AND NOT (role.id=4000005 AND role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='WAREHOUSE_PICKER')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA权限初始化失败:存在非仓库拣货员授权'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_pda_picker_permission(); +DROP PROCEDURE assert_overseas_pda_picker_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/main/resources/db/phase3-warehouse-roles.sql b/iam/src/main/resources/db/phase3-warehouse-roles.sql index 75bf068e1..5f39d36cf 100644 --- a/iam/src/main/resources/db/phase3-warehouse-roles.sql +++ b/iam/src/main/resources/db/phase3-warehouse-roles.sql @@ -140,6 +140,7 @@ DELETE role_permission WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR', 'WAREHOUSE_PICKER', 'WAREHOUSE_TALLY') + AND NOT (role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate') AND expected.permission_code IS NULL; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) 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 01b84837d..776ec6854 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -40,6 +40,7 @@ class OverseasPhase4PermissionSqlContractTest { Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); @@ -55,6 +56,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission", "'lastleg:pda:operate'"); + assertPdaPickerGrant(sql); assertCustomAdminGrant(sql); } @@ -89,6 +91,24 @@ class OverseasPhase4PermissionSqlContractTest { .containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS); } + private static void assertPdaPickerGrant(String sql) { + assertThat(sql).contains( + "permission.permission_code<>'lastleg:pda:operate'", + "role.id=4000005", + "assert_overseas_pda_picker_permission", + "存在非仓库拣货员授权", + "role.role_code='WAREHOUSE_PICKER'", + "permission.permission_code='lastleg:pda:operate'", + "role.role_code='PLATFORM_ADMIN'", + "DELETE role_permission FROM"); + int picker = sql.indexOf("role.role_code='WAREHOUSE_PICKER'"); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", picker); + int grantEnd = sql.indexOf(';', picker); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(picker); + assertThat(sql.substring(grantStart, grantEnd)).contains("lastleg:pda:operate"); + } + 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/repository/WarehouseRoleSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java index 9cb8406a9..36786af3e 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/WarehouseRoleSqlContractTest.java @@ -45,6 +45,7 @@ class WarehouseRoleSqlContractTest { .contains("依赖的三期平台权限尚未完整部署") .contains("DELETE role_permission") .contains("expected.permission_code IS NULL") + .contains("role.role_code = 'WAREHOUSE_PICKER' AND permission.permission_code = 'lastleg:pda:operate'") .contains("最小权限授权未完整写入") .contains("grant_row.tenant_id = 0) <> 19") .contains("(6000004, 4000004, 0, 'TENANT', NULL)") diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index b6d2cc413..99b6a8795 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -73,6 +73,7 @@ - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 ## 查询与租户约束 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 fa2816d4f..ce144e37c 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 @@ -34,11 +34,11 @@ public class ReceivingContainerController { @Resource private ReceivingContainerService service; @GetMapping("/candidates") - @Operation(summary = "List pending receiving containers for the authenticated customer") + @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.customerId(), identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey())); } @GetMapping("/{containerNo}") @@ -46,14 +46,14 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { Identity identity = identity(user); - return ApiResponse.success(service.detail(identity.customerId(), identity.operatorKey(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.prepare(identity.customerId(), identity.operatorKey(), identity.operatorName(), + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), containerNo, request)); } @@ -62,20 +62,20 @@ public class ReceivingContainerController { @RequestPart("file") MultipartFile file) { Identity identity = identity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); - return ApiResponse.success(service.uploadPhoto(identity.customerId(), identity.operatorKey(), user.userId(), file)); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { Identity identity = identity(user); - return ApiResponse.success(service.sign(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.current(identity.customerId(), identity.operatorKey())); + return ApiResponse.success(service.current(identity.operatorKey())); } @PostMapping("/current/cartons/resolve") @@ -83,7 +83,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolveCarton(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @PostMapping("/current/skus/resolve") @@ -91,7 +91,7 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolveSku(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @PostMapping("/current/pallets/resolve") @@ -99,41 +99,42 @@ public class ReceivingContainerController { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { Identity identity = identity(user); - return ApiResponse.success(service.resolvePallet(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { Identity identity = identity(user); - return ApiResponse.success(service.receiveBulk(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { Identity identity = identity(user); - return ApiResponse.success(service.receiveCarton(identity.customerId(), identity.operatorKey(), identity.operatorName(), request)); + return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.complete(identity.customerId(), identity.operatorKey(), request)); + return ApiResponse.success(service.complete(identity.operatorKey(), request)); } private static Identity identity(AuthenticatedUser user) { - if (user == null || user.tenantId() == null || user.tenantId() <= 0 || user.userId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated PDA customer identity is required"); + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required"); } if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } String operator = String.valueOf(user.userId()); - return new Identity(String.valueOf(user.tenantId()), operator, + return new Identity(operator, user.username() == null || user.username().isBlank() ? operator : user.username()); } - private record Identity(String customerId, String operatorKey, String operatorName) { } + private record Identity(String operatorKey, String operatorName) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java index c07beb72f..ca291794b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java @@ -8,9 +8,8 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerOperationMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_operation WHERE customer_id=#{customerId} " + - "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") - ReceivingContainerOperation find(@Param("customerId") String customerId, - @Param("operatorKey") String operatorKey, + @Select("SELECT * FROM ll_receiving_container_operation " + + "WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + ReceivingContainerOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java index 49485e0f6..b50e7eabf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java @@ -8,18 +8,15 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ReceivingContainerSessionMapper extends BaseMapper { - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_operator_key=#{activeOperatorKey} LIMIT 1") - ReceivingContainerSession findActiveByOperator(@Param("customerId") String customerId, - @Param("activeOperatorKey") String activeOperatorKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_operator_key=#{activeOperatorKey} LIMIT 1") + ReceivingContainerSession findActiveByOperator(@Param("activeOperatorKey") String activeOperatorKey); - @Select("SELECT * FROM ll_receiving_container_session WHERE customer_id=#{customerId} " + - "AND active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") - ReceivingContainerSession lockActiveByContainer(@Param("customerId") String customerId, - @Param("activeContainerKey") String activeContainerKey); + @Select("SELECT * FROM ll_receiving_container_session " + + "WHERE active_container_key=#{activeContainerKey} LIMIT 1 FOR UPDATE") + ReceivingContainerSession lockActiveByContainer(@Param("activeContainerKey") String activeContainerKey); } 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 4c6682853..1f616904a 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 @@ -80,16 +80,17 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String customerId, String operatorKey) { - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public List candidates(String operatorKey) { + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); Map> grouped = new LinkedHashMap<>(); - plans.selectList(new LambdaQueryWrapper().eq(InboundPlan::getCustomerId, customerId) + plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); return grouped.entrySet().stream().map(entry -> { + requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", @@ -98,9 +99,10 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String customerId, String operatorKey, String containerNo) { - List containerPlans = requirePlans(customerId, containerNo); - ReceivingContainerSession current = sessions.findActiveByOperator(customerId, activeOperatorKey(customerId, operatorKey)); + public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } @@ -109,27 +111,28 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session prepare(String customerId, String operatorKey, String operatorName, - String containerNo, ReceivingContainerRequests.Action request) { + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); - ReceivingContainerOperation replay = replay(customerId, operatorKey, "PREPARE", request.idempotencyKey(), command); + ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - List containerPlans = requirePlans(customerId, containerNo); - String activeOperatorKey = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession active = sessions.lockActiveByOperator(customerId, activeOperatorKey); + List containerPlans = requirePlans(containerNo); + String customerId = requireSingleCustomer(containerPlans, containerNo); + String activeOperatorKey = activeOperatorKey(operatorKey); + ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); save(active, "PREPARE", request.idempotencyKey(), command, result); return result; } - if (sessions.lockActiveByContainer(customerId, activeContainerKey(customerId, containerNo)) != null) { + if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); - session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(customerId, containerNo)); + session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); session.setVersion(0); try { sessions.insert(session); } @@ -138,20 +141,21 @@ public class ReceivingContainerService { save(session, "PREPARE", request.idempotencyKey(), command, result); return result; } - public ReceivingContainerResponses.Photo uploadPhoto(String customerId, String operatorKey, long operatorId, - MultipartFile file) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + public ReceivingContainerResponses.Photo uploadPhoto(String operatorKey, long operatorId, MultipartFile file) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing"); UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file); return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes()); } @Transactional - public ReceivingContainerResponses.Session sign(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Session sign(String operatorKey, String operatorName, ReceivingContainerRequests.Sign request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "SIGN", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "SIGN", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + ReceivingContainerSession session = requireCurrent(operatorKey, true); + String customerId = session.getCustomerId(); if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed"); List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList(); @@ -166,14 +170,15 @@ public class ReceivingContainerService { save(session, "SIGN", request.idempotencyKey(), request, result); return result; } - public ReceivingContainerResponses.Session current(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, false); - return snapshot(session, requirePlans(customerId, session.getContainerNo())); + public ReceivingContainerResponses.Session current(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, false); + return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); } - public ReceivingContainerResponses.CartonResolved resolveCarton(String customerId, String operatorKey, + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); @@ -190,9 +195,10 @@ public class ReceivingContainerService { inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } - public ReceivingContainerResponses.SkuResolved resolveSku(String customerId, String operatorKey, + public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, ReceivingContainerRequests.SkuResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List matches = refs(customerId, session.getContainerNo()).stream() .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .map(ref -> skuRef(ref, request.skuCode())) @@ -203,9 +209,10 @@ public class ReceivingContainerService { return skuResponse(matches.get(0)); } - public ReceivingContainerResponses.PalletResolved resolvePallet(String customerId, String operatorKey, + public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.resolvePallet(request.palletCode()); validatePallet(ref, pallet); @@ -214,11 +221,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveBulk(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveBulk(String operatorKey, String operatorName, ReceivingContainerRequests.BulkReceive request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); @@ -248,11 +256,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Receipt receiveCarton(String customerId, String operatorKey, String operatorName, + public ReceivingContainerResponses.Receipt receiveCarton(String operatorKey, String operatorName, ReceivingContainerRequests.CartonReceive request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); @@ -284,11 +293,12 @@ public class ReceivingContainerService { } @Transactional - public ReceivingContainerResponses.Session complete(String customerId, String operatorKey, + public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { - ReceivingContainerOperation replay = replay(customerId, operatorKey, "COMPLETE", request.idempotencyKey(), request); + ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); - ReceivingContainerSession session = requireSigned(customerId, operatorKey); + ReceivingContainerSession session = requireSigned(operatorKey); + String customerId = session.getCustomerId(); List containerPlans = requirePlans(customerId, session.getContainerNo()); Totals totals = totals(containerPlans); if (totals.total() != totals.received()) throw conflict("Every inbound code must be fully received before completing the container"); @@ -301,22 +311,33 @@ public class ReceivingContainerService { save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; } - private ReceivingContainerSession requireCurrent(String customerId, String operatorKey, boolean lock) { - String key = activeOperatorKey(customerId, operatorKey); - ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(customerId, key) - : sessions.findActiveByOperator(customerId, key); + private ReceivingContainerSession requireCurrent(String operatorKey, boolean lock) { + String key = activeOperatorKey(operatorKey); + ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) + : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); return session; } - private ReceivingContainerSession requireSigned(String customerId, String operatorKey) { - ReceivingContainerSession session = requireCurrent(customerId, operatorKey, true); + private ReceivingContainerSession requireSigned(String operatorKey) { + ReceivingContainerSession session = requireCurrent(operatorKey, true); if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { throw conflict("Sign the receiving container before receiving cargo"); } return session; } + private List requirePlans(String containerNo) { + List values = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + if (values.isEmpty()) throw notFound("Receiving container was not found"); + requireSingleCustomer(values, containerNo); + if (values.stream().allMatch(value -> "COMPLETED".equals(value.getStatus()))) { + throw conflict("Receiving container is already completed"); + } + return values; + } + private List requirePlans(String customerId, String containerNo) { List values = plansForContainer(customerId, containerNo); if (values.isEmpty()) throw notFound("Receiving container was not found"); @@ -334,6 +355,15 @@ public class ReceivingContainerService { return values; } + private String requireSingleCustomer(List values, String containerNo) { + Set customers = values.stream().map(InboundPlan::getCustomerId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).collect(Collectors.toCollection(java.util.LinkedHashSet::new)); + if (customers.size() != 1 || values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { + throw conflict("Receiving container must resolve to exactly one customer: " + containerNo); + } + return customers.iterator().next(); + } + private List refs(String customerId, String containerNo) { List result = new ArrayList<>(); for (InboundPlan plan : plansForContainer(customerId, containerNo)) { @@ -576,9 +606,9 @@ public class ReceivingContainerService { return value; } - private ReceivingContainerOperation replay(String customerId, String operatorKey, String action, String key, Object request) { + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { key = idempotencyKey(key); - ReceivingContainerOperation value = operations.find(customerId, operatorKey, key); + ReceivingContainerOperation value = operations.find(operatorKey, key); if (value == null) return null; if (!action.equals(value.getAction()) || !hash(request).equals(value.getRequestHash())) { throw conflict("Idempotency key was already used with a different request"); @@ -618,8 +648,8 @@ public class ReceivingContainerService { catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } } - private static String activeOperatorKey(String customerId, String operatorKey) { return customerId + ":" + operatorKey; } - private static String activeContainerKey(String customerId, String containerNo) { return customerId + ":" + containerNo; } + private static String activeOperatorKey(String operatorKey) { return "OPERATOR:" + operatorKey; } + private static String activeContainerKey(String containerNo) { return "CONTAINER:" + containerNo.trim().toUpperCase(java.util.Locale.ROOT); } private static String idempotencyKey(String value) { if (value == null || value.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency key is required"); value = value.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java index 0e9dc4d95..4ffeff2cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -40,8 +41,11 @@ public class InventoryLedger { private Long deltaPackages; private Long afterPackages; /** Quantity snapshot is retained even though current PDA operations are carton-count based. */ + @TableField(exist = false) private Long beforeQuantity; + @TableField(exist = false) private Long deltaQuantity; + @TableField(exist = false) private Long afterQuantity; private String operatorId; private String remark; 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 200b99a67..7d5d34abc 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 @@ -1,6 +1,8 @@ package com.baiyue.lastleg.procurement.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -17,24 +19,24 @@ public class OverseasProcurementOrder { private String orderNo; private String purchaseCategory; private String productName; - private String productSpecification; - private String productUrl; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productSpecification; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productUrl; private Long purchaseQuantity; private BigDecimal budgetUnitPrice; private String currency; private String receivingAddress; private String contactName; private String contactPhone; - private String purchaseWarehouseId; - private String purchaseWarehouseName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String purchaseWarehouseName; private Long inboundQuantity; - private String domesticCarrier; - private String domesticTrackingNo; - private String overseasCarrier; - private String overseasTrackingNo; - private LocalDateTime estimatedArrivalAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String domesticTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasCarrier; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String overseasTrackingNo; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime estimatedArrivalAt; private String status; - private String remark; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 430e98bf9..dd71d6b9a 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -678,10 +678,10 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) OR l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户账号标识', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', - operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话账号唯一锁键', - active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_operation ( operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容哈希', request_json LONGTEXT NOT NULL COMMENT '请求内容快照', result_json LONGTEXT NOT NULL COMMENT '响应内容快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), - UNIQUE KEY uk_ll_receiving_operation(customer_id,operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) + UNIQUE KEY uk_ll_receiving_operation(operator_key,idempotency_key), KEY idx_ll_receiving_operation_session(session_id,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号收货幂等审计记录'; CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( 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 ed8be5397..e7492edbf 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6,20 +6,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/candidates: get: summary: 查询当前客户待签收柜号 - description: 权限 lastleg:pda:operate;客户与操作账号均从 PDA Token 获取,不接收 customerId。 + description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用,操作账号从 Token 获取,客户由所选柜业务事实唯一解析,不接收 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}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 + description: 权限 lastleg:pda:operate;按柜号唯一解析客户并返回柜内全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;建立 WAIT_SIGN 会话并以账号、柜号双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;建立 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' } } } } 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 d49cf48e4..7e0a35e3e 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 @@ -47,7 +47,7 @@ class ReceivingContainerContractTest { } @Test - void schemaContainsCustomerScopedPlanChildrenAndContainerSessionLocks() throws IOException { + void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); String unified = java.nio.file.Files.readString( java.nio.file.Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8).toLowerCase(); @@ -62,7 +62,10 @@ class ReceivingContainerContractTest { assertTrue(schema.contains("create table if not exists ll_receiving_container_session")); assertTrue(schema.contains("unique key uk_ll_receiving_active_operator(active_operator_key)")); assertTrue(schema.contains("unique key uk_ll_receiving_active_container(active_container_key)")); + assertTrue(schema.contains("平台操作账号全局唯一锁键")); + assertTrue(schema.contains("柜号全局唯一锁键")); assertTrue(schema.contains("create table if not exists ll_receiving_container_operation")); + assertTrue(schema.contains("unique key uk_ll_receiving_operation(operator_key,idempotency_key)")); assertTrue(schema.contains("create table if not exists ll_receiving_sku_receipt")); assertTrue(schema.contains("unique key uk_ll_receiving_sku_pallet (customer_id,line_id,pallet_id,sku_code)")); String receivingSession = tableBlock(schema, "ll_receiving_container_session"); @@ -75,6 +78,11 @@ class ReceivingContainerContractTest { assertTrue(unified.contains("customer_id is null or v.customer_id=''")); assertTrue(unified.contains("customer_id is null or a.customer_id=''")); assertTrue(unified.contains("signal sqlstate '45000'")); + assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("concat('operator:',operator_key)")); + assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); + assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); + assertTrue(unified.contains("group_concat(column_name order by seq_in_index)= 'operator_key,idempotency_key'")); } @Test @@ -94,6 +102,8 @@ class ReceivingContainerContractTest { assertTrue(successResponses.stream().allMatch(line -> line.contains("content: { application/json: { schema: { $ref: '#/components/schemas/Receiving"))); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); + assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); + assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取")); assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]")); assertFalse(receivingPaths.contains("photoFileIds")); } 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 c9c3d0fb7..dd7fa8bef 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 @@ -30,25 +30,32 @@ class ReceivingContainerControllerTest { } @Test - void candidatesDerivesCustomerAndOperatorOnlyFromPdaIdentity() { - when(service.candidates("100", "42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( + void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { + when(service.candidates("42")).thenReturn(List.of(new ReceivingContainerResponses.Candidate( "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); - controller.candidates(pda()); + controller.candidates(picker()); - verify(service).candidates("100", "42"); + verify(service).candidates("42"); } @Test - void platformIdentityCannotEnterPdaReceivingApi() { - AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of("lastleg:pda:operate")); - assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { + AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); + assertThatThrownBy(() -> controller.candidates(merchant)).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("401"); } - private static AuthenticatedUser pda() { - return new AuthenticatedUser(42L, 100L, 100L, 8L, "receiver", "PDA", "PDA", - Set.of("lastleg:pda:operate")); + @Test + void platformIdentityWithoutPickerAuthorityIsForbidden() { + AuthenticatedUser platform = new AuthenticatedUser(7L, 0L, 0L, 8L, "admin", "PLATFORM", "WEB", Set.of()); + assertThatThrownBy(() -> controller.candidates(platform)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("403"); + } + + private static AuthenticatedUser picker() { + return new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA", + Set.of(ReceivingContainerController.OPERATE_PERMISSION)); } } 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 498415847..44646e54d 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 @@ -35,6 +35,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -87,35 +88,56 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of()); when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); - service.prepare("100", "7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); + service.prepare("7", "receiver", "CONT-A", new ReceivingContainerRequests.Action("same-key")); + ArgumentCaptor selected = ArgumentCaptor.forClass(ReceivingContainerSession.class); + verify(sessions).insert(selected.capture()); + assertThat(selected.getValue().getCustomerId()).isEqualTo("100"); + assertThat(selected.getValue().getActiveOperatorKey()).isEqualTo("OPERATOR:7"); + assertThat(selected.getValue().getActiveContainerKey()).isEqualTo("CONTAINER:CONT-A"); ArgumentCaptor saved = ArgumentCaptor.forClass(ReceivingContainerOperation.class); verify(operations).insert(saved.capture()); - when(operations.find("100", "7", "same-key")).thenReturn(saved.getValue()); + when(operations.find("7", "same-key")).thenReturn(saved.getValue()); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("409"); } + @Test + void pickerCandidatesSpanCustomersButEachContainerMustResolveOneCustomer() { + InboundPlan first = plan("CONT-A"); + InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); + when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second)); + when(lines.selectList(any())).thenReturn(List.of()); + + assertThat(service.candidates("7")).extracting("containerNo").containsExactly("CONT-A", "CONT-B"); + + InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200"); + org.mockito.Mockito.reset(plans, sessions); + when(plans.selectList(any())).thenReturn(List.of(first, ambiguous)); + assertThatThrownBy(() -> service.candidates("7")).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("exactly one customer"); + } + @Test void waitSignSelectionAlreadyLocksAccountFromViewingAnotherContainer() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.findActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("100", "7", "CONT-B")) + when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.detail("7", "CONT-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @Test void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); - when(sessions.lockActiveByContainer("100", "100:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("100", "7", "receiver", "CONT-B", + when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); + assertThatThrownBy(() -> service.prepare("7", "receiver", "CONT-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -123,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -132,7 +154,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); InboundPlanCarton carton = carton(1L, "BOX-1"); when(cartons.selectOne(any())).thenReturn(carton); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -142,7 +164,7 @@ class ReceivingContainerServiceBehaviorTest { void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() { stubCurrent("OVERSEAS", "CONT-A"); when(cartons.selectOne(any())).thenReturn(carton(999L, "BOX-X")); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-X", "PAL-1", "cross-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -151,7 +173,7 @@ class ReceivingContainerServiceBehaviorTest { stubCurrent("FBA", "CONT-A"); stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -165,7 +187,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -177,7 +199,7 @@ class ReceivingContainerServiceBehaviorTest { when(pallets.selectList(any())).thenReturn(List.of(planPallet())); doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "duplicate carton")) .when(workflow).validateCartonResolved(any(), any(), any(), any(), any()); - assertThatThrownBy(() -> service.receiveCarton("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "duplicate-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -189,11 +211,24 @@ class ReceivingContainerServiceBehaviorTest { InboundPlanPalletAllocation partial = new InboundPlanPalletAllocation(); partial.setLineId(10L); partial.setPickedPackageCount(1); when(allocations.selectList(any())).thenReturn(List.of(partial)); - assertThatThrownBy(() -> service.complete("100", "7", new ReceivingContainerRequests.Action("complete-key"))) + assertThatThrownBy(() -> service.complete("7", new ReceivingContainerRequests.Action("complete-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).completeContainer(any(), any()); } + @Test + void completedRequestReplaysWithoutAnActiveCustomerSession() { + ReceivingContainerOperation operation = new ReceivingContainerOperation(); + operation.setOperatorKey("7"); operation.setAction("COMPLETE"); operation.setIdempotencyKey("complete-replay"); + operation.setRequestHash(hash(new ReceivingContainerRequests.Action("complete-replay"))); + operation.setResultJson("{\"containerNo\":\"CONT-A\",\"status\":\"COMPLETED\",\"operatorName\":\"receiver\",\"version\":1,\"photos\":[],\"totalPackages\":2,\"receivedPackages\":2,\"pendingPackages\":0,\"inbounds\":[]}"); + when(operations.find("7", "complete-replay")).thenReturn(operation); + + assertThat(service.complete("7", new ReceivingContainerRequests.Action("complete-replay")).status()) + .isEqualTo("COMPLETED"); + verifyNoInteractions(sessions); + } + @Test void localFailureAfterRemoteReservationTriggersReleaseCompensation() { stubCurrent("FBA", "CONT-A"); @@ -204,7 +239,7 @@ class ReceivingContainerServiceBehaviorTest { ResponseStatusException failure = new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "race"); doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); @@ -212,7 +247,7 @@ class ReceivingContainerServiceBehaviorTest { @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); when(lines.selectList(any())).thenReturn(List.of()); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); @@ -222,7 +257,7 @@ class ReceivingContainerServiceBehaviorTest { new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key"); - var response = service.sign("100", "7", "receiver", request); + var response = service.sign("7", "receiver", request); org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role") .containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL, @@ -234,26 +269,26 @@ class ReceivingContainerServiceBehaviorTest { @Test void signRejectsDuplicateRolesBeforeFileValidation() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign"); - assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(unloadPhotos); } @Test void signRejectsDuplicateFileIdsBeforeFileValidation() { - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L), new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files"); - assertThatThrownBy(() -> service.sign("100", "7", "receiver", request)) + assertThatThrownBy(() -> service.sign("7", "receiver", request)) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(unloadPhotos); } @@ -267,7 +302,7 @@ class ReceivingContainerServiceBehaviorTest { received.setReceivedPackages(2); when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); - assertThatThrownBy(() -> service.receiveBulk("100", "7", "receiver", + assertThatThrownBy(() -> service.receiveBulk("7", "receiver", new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -277,7 +312,7 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); LastlegOrder order = order(destinationType); - when(sessions.lockActiveByOperator("100", "100:7")).thenReturn(session(containerNo, "SIGNED")); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); when(orders.selectById(1L)).thenReturn(order); @@ -342,4 +377,17 @@ class ReceivingContainerServiceBehaviorTest { return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, 40L, "AVAILABLE", fbaCode, 0); } + + private String hash(Object value) { + try { + String json = new ObjectMapper().findAndRegisterModules().copy() + .configure(com.fasterxml.jackson.databind.MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .writeValueAsString(value); + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(json.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java index 2586fae27..d35865d93 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -108,9 +109,12 @@ class InventorySchemaContractTest { assertThrowsNoField(InventoryPosition.class, "productQuantity"); assertNotNull(InventoryLedger.class.getDeclaredField("skuCode")); assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark")); - assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity")); - assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity")); + for (String field : List.of("beforeQuantity", "deltaQuantity", "afterQuantity")) { + var annotation = InventoryLedger.class.getDeclaredField(field) + .getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class); + assertNotNull(annotation, field); + assertFalse(annotation.exist(), field); + } } private static void assertThrowsNoField(Class type, String fieldName) { 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 f60b35062..9e41c94e8 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 @@ -1,5 +1,7 @@ package com.baiyue.lastleg.procurement.service; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests; import com.baiyue.lastleg.procurement.entity.OverseasProcurementOrder; import com.baiyue.lastleg.procurement.mapper.OverseasProcurementOrderMapper; @@ -21,6 +23,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -155,4 +158,15 @@ class OverseasProcurementServiceTest { order.setUpdatedAt(order.getCreatedAt()); return order; } + + @Test + void nullablePatchFieldsArePersistedWhenExplicitlyCleared() throws Exception { + for (String field : List.of("productSpecification", "productUrl", "purchaseWarehouseId", + "purchaseWarehouseName", "domesticCarrier", "domesticTrackingNo", "overseasCarrier", + "overseasTrackingNo", "estimatedArrivalAt", "remark")) { + TableField annotation = OverseasProcurementOrder.class.getDeclaredField(field).getAnnotation(TableField.class); + assertNotNull(annotation, field); + assertEquals(FieldStrategy.ALWAYS, annotation.updateStrategy(), field); + } + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 4bd6fb8ad..756c1a027 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -7,6 +7,49 @@ import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const MODULE_DEPENDENCIES = new Map([ + ['orders', ['merchant-qualification-fixture']], + ['inbound-measurement', ['orders']], + ['phase3-container-loading', ['inbound-measurement']], + ['phase3-loading', ['phase3-container-loading']], + ['phase3-container-overview', ['phase3-loading']], + ['phase3-loading-model-bridge', ['phase3-loading']], + ['phase3-pda-convergence', ['phase3-loading']], + ['phase3-evidence-gate', ['phase3-pda-convergence']], + ['ocean-bookings', ['phase3-loading']], + ['truck-appointments', ['merchant-qualification-fixture']], + ['service-center-flow', ['ocean-bookings', 'truck-appointments']], + ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], + ['phase3-customs', ['phase3-loading-finalize']], + ['phase3-port-schedules', ['ocean-bookings']], + ['phase3-outbound-dispatch', ['phase3-customs']], + ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], + ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], + ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], + ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], + ['phase3-order-splitting', ['phase3-loading-finalize']], + ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], + ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], + ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], +]) + +function stableTopologicalOrder(modules) { + const selected = new Set(modules) + const pending = [...modules] + const result = [] + const completed = new Set() + while (pending.length > 0) { + const readyIndex = pending.findIndex((module) => (MODULE_DEPENDENCIES.get(module) || []) + .every((dependency) => !selected.has(dependency) || completed.has(dependency))) + if (readyIndex < 0) throw new Error(`HTTP 验收模块依赖存在循环: ${pending.join(',')}`) + const [module] = pending.splice(readyIndex, 1) + result.push(module) + completed.add(module) + } + return result +} + export function normalizeModuleOrder(modules) { const normalized = [...modules] const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking'] @@ -68,9 +111,9 @@ export function normalizeModuleOrder(modules) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] - const withoutPrerequisites = normalized.filter((module) => !prerequisites.includes(module)) - withoutPrerequisites.splice(withoutPrerequisites.indexOf('phase4-cross-region-events'), 0, ...prerequisites) - normalized.splice(0, normalized.length, ...withoutPrerequisites) + const targetIndex = normalized.indexOf('phase4-cross-region-events') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) @@ -122,10 +165,11 @@ export function normalizeModuleOrder(modules) { } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') - if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized - normalized.splice(overviewIndex, 1) - normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') - return normalized + if (loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex) { + normalized.splice(overviewIndex, 1) + normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') + } + return stableTopologicalOrder(normalized) } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 14ff86e4b..ebce176a2 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -315,7 +315,8 @@ function assertCargoContract(context, response, cargoes, payload) { && Number(actual?.productLengthCm) === expected.productLengthCm && Number(actual?.productWidthCm) === expected.productWidthCm && Number(actual?.productHeightCm) === expected.productHeightCm - && actual?.shipmentId === expected.shipmentId && actual?.referenceId === expected.referenceId + && (actual?.shipmentId ?? null) === (expected.shipmentId ?? null) + && (actual?.referenceId ?? null) === (expected.referenceId ?? null) && actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker) }) context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs new file mode 100644 index 000000000..5c4da9dbd --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { markerSuffix } from './phase3-loading.mjs' + +test('phase3 loading fixture identifiers are deterministic and distinct across marker variants', () => { + const base = 'ADMIN-API-20260824123024536' + const primary = markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`) + const secondary = markerSuffix(`${base}-P4-SECONDARY`) + + assert.equal(primary, markerSuffix(`${base}-P4-OVERSEAS-PRIMARY`)) + assert.notEqual(primary, secondary) + assert.match(primary, /^\d{8}-[0-9A-F]{8}$/) + assert.ok(`P3B-${primary}`.length <= 64) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 0b64243f8..61b36ae98 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,4 +1,5 @@ import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' +import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } @@ -6,6 +7,12 @@ const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' function id(value) { return value == null ? '' : String(value) } +export function markerSuffix(marker) { + const value = String(marker || '') + const digits = value.replace(/\D/g, '').slice(-8).padStart(8, '0') + const digest = createHash('sha256').update(value).digest('hex').slice(0, 8).toUpperCase() + return `${digits}-${digest}` +} function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function responseEvidence(response) { return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` @@ -119,7 +126,7 @@ export async function run(context) { if (initialStatus !== 'WAIT_SPLIT' || !Number.isInteger(initialVersion)) return const base = `/api/platform/v1/inbounds/${inboundId}` - const suffix = context.marker.replace(/\D/g, '').slice(-10) + const suffix = markerSuffix(context.marker) const warehouseId = id(initial?.summary?.warehouseId) const containerNo = `P3B-${suffix}`.slice(0, 64) const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index cc496ce32..eb586afb5 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -33,10 +33,11 @@ export async function run(context) { const roleCodes = (roleCode) => new Set((warehouseRoles.get(roleCode)?.permissionIds || []) .map((permissionId) => permissionById.get(id(permissionId))).filter(Boolean)) const pickerCodes = roleCodes('WAREHOUSE_PICKER') - context.assert(MODULE, '拣货员只具备PDA查询、作业和现场拍照权限', rolesResponse, - pickerCodes.size === 2 + context.assert(MODULE, '拣货员只具备国内PDA查询作业与海外PDA入仓操作权限', rolesResponse, + pickerCodes.size === 3 && pickerCodes.has('platform:firstleg:pda:view') - && pickerCodes.has('platform:firstleg:pda:work'), + && pickerCodes.has('platform:firstleg:pda:work') + && pickerCodes.has('lastleg:pda:operate'), `permissions=${[...pickerCodes].sort().join(',')}`, OPTIONS) const operatorCodes = roleCodes('WAREHOUSE_OPERATOR') diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 0c324fa98..2dab0d4e4 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -58,6 +58,25 @@ test('entry orders marker-owned prerequisites before cross-region acceptance', ( } }) +test('cross-region normalization keeps existing producers ahead of every phase-three consumer', () => { + const requested = [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', + 'phase3-loading-model-bridge', 'phase3-loading', 'ocean-bookings', 'phase3-loading-finalize', + 'phase3-customs', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', + 'phase4-overseas-pda', 'phase4-cross-region-events', + ] + const modules = normalizeModuleOrder(requested) + for (const [producer, consumer] of [ + ['merchant-qualification-fixture', 'orders'], ['orders', 'inbound-measurement'], + ['inbound-measurement', 'phase3-container-loading'], ['phase3-container-loading', 'phase3-loading'], + ['phase3-loading', 'phase3-loading-model-bridge'], ['phase3-loading', 'ocean-bookings'], + ['phase3-loading-finalize', 'phase3-customs'], ['phase3-customs', 'phase3-outbound-dispatch'], + ['phase3-outbound-dispatch', 'phase3-outbound-aggregate'], + ['phase4-overseas-pda', 'phase4-cross-region-events'], + ]) assert.ok(modules.indexOf(producer) < modules.indexOf(consumer), `${producer} -> ${consumer}`) + assert.deepEqual(normalizeModuleOrder(modules), modules) +}) + test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { for (const token of [ 'context.token)', 'context.overseasToken)', 'cn-overseas-event-not-delivered', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index e614ef63e..284dbe9b6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -29,6 +29,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /同键同载荷重放/) assert.match(source, /同键异载荷冲突/) assert.match(source, /跨柜切换锁返回409/) + assert.match(source, /第二仓库操作员抢占同柜返回409/) + assert.match(source, /WAREHOUSE_PICKER/) + assert.match(source, /lastleg:pda:operate/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /模式硬拒绝/) @@ -37,43 +40,60 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) }) -test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => { +test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { const calls = []; const gaps = []; const steps = [] const context = { marker: 'PDA-CONTRACT', context: {}, createdResources: {}, - async request(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async request(method, requestPath) { + calls.push({ method, requestPath }) + return { method, path: requestPath, http: 200, body: { code: '200', data: [] }, + requestId: 'request-contract-1', traceId: 'trace-contract-0001' } + }, async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') }, async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, + apiSuccess(module, name, response) { return response.body.data }, addStep(...args) { steps.push(args) }, assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, } await run(context) - assert.equal(calls.length, 0) - assert.equal(gaps.length, 1) - assert.equal(gaps[0][5].deploymentBlocker, true) - assert.equal(steps.length, 1) - assert.equal(steps[0].passed, false) - assert.equal(steps[0].options.deploymentBlocker, true) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/roles')) + assert.ok(calls.some((call) => call.requestPath === '/api/platform/v1/iam/permissions')) + assert.ok(gaps.some((gap) => gap[2] === '海外PDA仓库操作员权限')) + assert.ok(steps.some((step) => step?.passed === false && step?.options?.deploymentBlocker)) }) test('empty candidate response cannot become a zero-business-request pass', async () => { const calls = []; const gaps = []; const steps = [] const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200, body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' }) + let userSequence = 0 + const roleId = '4000005'; const permissionId = '5107015' const context = { - marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, - context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } }, - async request(method, requestPath) { + marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, context: {}, + async findAvailablePhone() { userSequence += 1; return `1750000000${userSequence}` }, + async request(method, requestPath, body) { calls.push({ channel: 'domestic', method, requestPath }) - return response({ overseasAccessToken: 'pda-token' }, requestPath, method) + if (method === 'GET' && requestPath.endsWith('/roles')) return response([ + { id: roleId, roleCode: 'WAREHOUSE_PICKER', status: 'ACTIVE', permissionIds: [permissionId] }, + ], requestPath, method) + if (method === 'GET' && requestPath.endsWith('/permissions')) return response([ + { id: permissionId, permissionCode: 'lastleg:pda:operate' }, + ], requestPath, method) + if (method === 'POST' && requestPath.endsWith('/users')) return response({ + membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId], status: 'ACTIVE', + }, requestPath, method) + if (method === 'POST' && requestPath === '/api/auth/login') return response({ overseasAccessToken: `pda-token-${userSequence}` }, requestPath, method) + if (method === 'PUT' && /\/users\/\d+\/status$/.test(requestPath)) return response({ status: 'DISABLED' }, requestPath, method) + throw new Error(`unexpected domestic request ${method} ${requestPath}`) }, - async requestOverseas(method, requestPath) { + async requestOverseas(method, requestPath, body, headers, token) { calls.push({ channel: 'overseas', method, requestPath }) if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } return response([], requestPath, method) }, + apiSuccess(module, name, result) { return result.body.data }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, @@ -84,4 +104,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker)) assert.equal(gaps.length, 1) assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null) + assert.equal(context.createdResources.phase4OverseasPda.pickerMembershipIds.length, 2) + assert.equal(calls.filter((call) => /\/users\/\d+\/status$/.test(call.requestPath)).length, 2) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 4a66ab7e1..607920643 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -35,20 +35,58 @@ async function expected(context, token, method, name, path, body, predicate) { return response } -async function loginPda(context) { +async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture - if (!fixture?.username || !fixture?.password) { - blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块') - return null - } + if (!fixture?.username || !fixture?.password) return null const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') const data = success(response) ? response.body?.data : null - context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response, + context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, success(response) && nonBlank(data?.overseasAccessToken), `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null } +async function createPicker(context, index) { + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(MODULE, `查询海外PDA仓库操作员角色${index}`, rolesResponse, '', OPTIONS) + const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'WAREHOUSE_PICKER' && item?.status === 'ACTIVE') + const permissionsResponse = await context.request('GET', '/api/platform/v1/iam/permissions') + const permissions = context.apiSuccess(MODULE, `查询海外PDA权限资源${index}`, permissionsResponse, '', OPTIONS) + const operate = (Array.isArray(permissions) ? permissions : []).find((item) => item?.permissionCode === 'lastleg:pda:operate') + const assigned = Boolean(role && operate && (role.permissionIds || []).map(id).includes(id(operate.id))) + context.assert(MODULE, `WAREHOUSE_PICKER精确具备海外PDA权限${index}`, rolesResponse, assigned, + `roleId=${id(role?.id) || 'missing'}, permissionId=${id(operate?.id) || 'missing'}`, OPTIONS) + if (!assigned) { + blockerGap(context, '海外PDA仓库操作员权限', 'WAREHOUSE_PICKER未获得lastleg:pda:operate;必须先执行最新国内统一增量SQL并部署IAM') + return null + } + const suffix = markerSuffix(context) + const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) + const password = `Pda${suffix.replace(/[^A-Za-z0-9]/g, '')}A${index}9` + const phone = await context.findAvailablePhone(MODULE) + const payload = { username, displayName: `海外PDA验收操作员${index}-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [id(role.id)], password, status: 'ACTIVE' } + const createdResponse = await context.request('POST', '/api/platform/v1/iam/users', payload) + const created = context.apiSuccess(MODULE, `创建海外PDA仓库操作员${index}`, createdResponse, '', OPTIONS) + const membershipId = id(created?.membershipId) + context.assert(MODULE, `海外PDA操作员${index}冻结平台范围与角色`, createdResponse, + longId(membershipId) && id(created?.tenantId) === '0' && (created?.roleIds || []).map(id).includes(id(role.id)), + `membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}`, OPTIONS) + if (!longId(membershipId)) return null + const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const token = success(login) ? login.body?.data?.overseasAccessToken : null + context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), + `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) + return { membershipId, username, token: nonBlank(token) ? token : null } +} + +async function disablePicker(context, picker, index) { + if (!longId(picker?.membershipId)) return + const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' }) + const disabled = success(response) && response.body?.data?.status === 'DISABLED' + context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS) +} + async function createPallet(context, inbound, index) { const warehouseId = id(inbound?.destinationWarehouseId) if (!longId(warehouseId)) return null @@ -160,17 +198,14 @@ async function receiveOverseas(context, token, inbound, pallet) { return true } -export async function run(context) { - context.createdResources.phase4OverseasPda = { - marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null, - palletIds: [], locationIds: [], finalStatus: null, - cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', - } - const token = await loginPda(context) - if (!token) return +async function runReceiving(context, token, secondaryToken, merchantToken) { if (context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) - context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + if (merchantToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) + context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) const noCurrent = notFound(before) @@ -203,6 +238,11 @@ export async function run(context) { `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + if (secondaryToken) { + await expected(context, secondaryToken, 'POST', '第二仓库操作员抢占同柜返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, + { idempotencyKey: key(context, 'secondary-prepare') }, conflict) + } const other = candidates.data.find((item) => item.containerNo !== containerNo) if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) @@ -268,3 +308,27 @@ export async function run(context) { context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null } + +export async function run(context) { + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: null, + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], + cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + } + const merchantToken = await loginMerchantForNegativeProbe(context) + const primary = await createPicker(context, 1) + const secondary = primary?.token ? await createPicker(context, 2) : null + for (const picker of [primary, secondary]) if (picker?.membershipId) { + context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) + } + try { + if (!primary?.token || !secondary?.token) { + blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') + return + } + await runReceiving(context, primary.token, secondary.token, merchantToken) + } finally { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 24e9c3b61..10d20e210 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), 'utf8') function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } +function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null @@ -21,12 +22,12 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return response(null) } - if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return response(null) } + if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') - createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return response(null) + createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() } if (method === 'POST' && requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) { if (createdRelabel && body.remark !== createdRelabel.remark) return response(null, 'A0409') @@ -51,7 +52,8 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return response(createdLocation) + if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null + ? response(undefined, 'A0404') : response(createdLocation) if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) @@ -86,6 +88,14 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) assert.ok(writes.some((call) => call.path.includes('/relabel-applications'))) assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/') || call.path.includes('/relabel-applications'))) + for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { + const step = context.steps.find((item) => item.name === name) + assert.ok(step?.passed, name) + assert.equal(Object.hasOwn(step.result?.body || {}, 'data'), false, name) + } + const deletedReadback = context.steps.find((step) => step.name === '本轮公共库位已删除且回查不存在') + assert.equal(deletedReadback?.result?.body?.code, 'A0404') + assert.ok(deletedReadback?.passed) assert.ok(context.steps.every((step) => step.passed), context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) }) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 0d64db727..3e977f3aa 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -55,6 +55,13 @@ function assertPage(context, name, result) { function businessConflict(response) { return response?.http === 409 || (response?.http === 200 && String(response?.body?.code) === 'A0409') } +function voidMutationSucceeded(result) { + return result?.response?.http === 200 && String(result?.response?.body?.code) === '200' + && result.data == null +} +function businessNotFound(response) { + return response?.http === 404 || (response?.http === 200 && String(response?.body?.code) === 'A0404') +} export async function run(context) { context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } @@ -216,7 +223,7 @@ async function verifyLocationLifecycle(context, warehouseId) { zoneCode: 'ACC', locationCode, locationType: 'STORAGE', maxLoadKg: 1200, status: 'ACTIVE', remark: `${context.marker}-edited`, }) - context.assert(MODULE, '公共库位编辑成功', updated.response, updated.data === null, + context.assert(MODULE, '公共库位编辑成功', updated.response, voidMutationSucceeded(updated), `locationId=${locationId}; ${trace(updated.response)}`, OPTIONS) const after = await overseasGet(context, '回查公共库位编辑结果', detailPath) context.assert(MODULE, '公共库位编辑字段已生效', after.response, @@ -226,9 +233,12 @@ async function verifyLocationLifecycle(context, warehouseId) { if (locationId) { const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) - context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = removed.data === null ? 'DELETED' : null - context.assert(MODULE, '本轮公共库位已删除收口', removed.response, removed.data === null, - `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; ${trace(removed.response)}`, OPTIONS) + const deletedDetail = await context.requestOverseas('GET', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) + context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null + context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, + `locationId=${locationId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.locationTerminalStatus || 'missing'}; delete=${trace(removed.response)}, readback=${trace(deletedDetail)}`, OPTIONS) } } } @@ -354,7 +364,7 @@ async function verifyPalletLifecycle(context, warehouseId) { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'IDLE', locationId: id(detail.data?.locationId) || undefined, remark: `${context.marker}-edited`, version: Number(detail.data?.version), }) - context.assert(MODULE, '公共托盘编辑成功', updated.response, updated.data === null, + context.assert(MODULE, '公共托盘编辑成功', updated.response, voidMutationSucceeded(updated), `palletId=${palletId}; ${trace(updated.response)}`, OPTIONS) const after = await overseasGet(context, '回查公共托盘编辑结果', detailPath) context.assert(MODULE, '公共托盘编辑字段已生效', after.response, @@ -376,8 +386,8 @@ async function verifyPalletLifecycle(context, warehouseId) { '读取公共托盘报废前最新版本', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), }) - context.createdResources.phase4OverseasWarehouse.terminalStatus = retired.data === null ? 'SCRAPPED' : null - context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, retired.data === null, + context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null + context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired), `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java index ab4d06ec6..29d409b18 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -34,7 +34,7 @@ public class MerchantWarehouseController { @Operation( summary = "List available warehouses / 查询可用仓库", - description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + description = "Customer scope comes only from the authenticated JWT; any customerId query value is ignored. / 客户范围仅从认证 JWT 获取,查询中的 customerId 不生效。" ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index 9467901d7..f934717af 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -91,7 +91,8 @@ public class PlatformWarehouseController { @Operation( summary = "List warehouses / 查询仓库", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns all warehouses with optional filters." + description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + + ". Returns warehouses across customers, or only the optional customerId scope when supplied." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 034dde3c6..7da4dc669 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -94,6 +95,8 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") public record QueryRequest( + @Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true) + @Positive Long customerId, @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 5e0bb2ca0..cfb36643f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -87,6 +87,7 @@ public class WarehouseRepository { public PageResult pageAll(WarehouseRequests.QueryRequest query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId()) .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) .orderByDesc(WarehouseEntity::getCreatedAt); @@ -94,6 +95,7 @@ public class WarehouseRepository { if (StringUtils.hasText(query.capabilityCode())) { List ids = capabilities.selectList(new LambdaQueryWrapper() .select(ServiceCapabilityEntity::getWarehouseId) + .eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId()) .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) .eq(ServiceCapabilityEntity::getEnabled, true)) .stream() diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java new file mode 100644 index 000000000..2a5b57d42 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MerchantWarehouseControllerTest { + @Test + void merchantPageIgnoresRequestedCustomerAndUsesAuthenticatedCustomer() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + MerchantWarehouseController controller = new MerchantWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest( + 99999L, 1, 20, "US", null, "ACTIVE"); + when(service.page(10001L, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + controller.page(merchant(10001L), query); + + verify(service).page(10001L, query); + } + + private static AuthenticatedUser merchant(long customerId) { + return new AuthenticatedUser(1L, customerId, customerId, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 1206986b8..0e4dea80f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; @@ -16,6 +18,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; class PlatformWarehouseControllerTest { @Test @@ -30,11 +33,11 @@ class PlatformWarehouseControllerTest { } @Test - void platformPageQueriesAllWarehousesWithoutCustomerId() { + void platformPagePassesOptionalCustomerFilterToApplicationService() { WarehouseApplicationService service = mock(WarehouseApplicationService.class); PlatformWarehouseController controller = new PlatformWarehouseController(); ReflectionTestUtils.setField(controller, "service", service); - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", null, null); + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, null, null, "ACTIVE"); when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); controller.page(platform(), query); @@ -42,6 +45,22 @@ class PlatformWarehouseControllerTest { verify(service).pageAll(query); } + @Test + void platformPageOpenApiExposesOptionalCustomerScope() throws Exception { + var customerId = WarehouseRequests.QueryRequest.class.getRecordComponents()[0]; + Schema schema = WarehouseRequests.QueryRequest.class.getDeclaredMethod("customerId") + .getAnnotation(Schema.class); + Operation operation = PlatformWarehouseController.class + .getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class) + .getAnnotation(Operation.class); + + assertEquals("customerId", customerId.getName()); + assertEquals(Long.class, customerId.getType()); + assertTrue(schema.nullable()); + assertTrue(schema.description().contains("platform")); + assertTrue(operation.description().contains("customerId")); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java new file mode 100644 index 000000000..821981179 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java @@ -0,0 +1,42 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.junit.jupiter.api.Test; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class WarehouseRepositoryTest { + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void platformPageAllFiltersByOptionalCustomerId() { + WarehouseMapper warehouses = mock(WarehouseMapper.class); + WarehouseRepository repository = new WarehouseRepository(); + ReflectionTestUtils.setField(repository, "warehouses", warehouses); + ReflectionTestUtils.setField(repository, "capabilities", mock(ServiceCapabilityMapper.class)); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "warehouse-overseas-query-test"), + WarehouseEntity.class); + when(warehouses.selectPage(any(Page.class), any(LambdaQueryWrapper.class))) + .thenReturn(new Page(1, 20)); + + repository.pageAll(new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE")); + + var queryCaptor = org.mockito.ArgumentCaptor.forClass(LambdaQueryWrapper.class); + org.mockito.Mockito.verify(warehouses).selectPage(any(Page.class), queryCaptor.capture()); + LambdaQueryWrapper query = queryCaptor.getValue(); + assertThat(query.getSqlSegment()).contains("customer_id"); + assertThat(query.getParamNameValuePairs()).containsValue(10001L); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 4852efa8f..1bdcc2925 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -50,8 +50,8 @@ class WarehouseApplicationServiceTest { } @Test - void platformWarehousePageUsesGlobalRepositoryQuery() { - WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(1, 20, "ACTIVE", "US", null); + void platformWarehousePagePassesCustomerScopedQueryToRepository() { + WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE"); when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0)); service.pageAll(query); diff --git a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs index cfcd5a871..72214465c 100644 --- a/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs +++ b/warehouse/src/test/scripts/warehouse-public-master-data-contract.test.mjs @@ -111,6 +111,9 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/) assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/) assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/) assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/) -assert.doesNotMatch(sql, /INSERT INTO wh_service_capability/i) +assert.match(sql, /国内固定作业仓必须启用收货能力/) +assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i) +const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data')) +assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i) assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/) assert.match(sql, /FBA公共地址不得创建平台作业能力/) From 8cc4c6f6da823fa23b3898f8a3c495c97628a231 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 21:46:19 +0800 Subject: [PATCH 123/231] fix: bind overseas receiving to shared warehouse --- .../auth/client/AuthorizationSnapshot.java | 8 ++ .../com/baiyue/auth/service/AuthService.java | 9 +- .../auth/service/OverseasTokenService.java | 9 +- .../auth/service/AuthServiceSmallJwtTest.java | 5 +- .../service/OverseasTokenServiceTest.java | 5 +- .../common/security/AuthenticatedUser.java | 12 ++- .../common/security/JwtClaimsMapper.java | 3 +- .../common/security/JwtClaimsMapperTest.java | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 8 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 55 +++++++++++ .../com/baiyue/iam/dto/AuthorizationDtos.java | 8 ++ .../iam/dto/PlatformUserManagementDtos.java | 18 +++- .../com/baiyue/iam/entity/IamMembership.java | 2 +- .../iam/mapper/AuthorizationQueryMapper.java | 4 + .../service/AuthorizationSnapshotService.java | 3 +- .../PlatformUserManagementService.java | 22 ++++- iam/src/main/resources/db/iam.sql | 1 + iam/src/main/resources/openapi/iam-v1.yaml | 3 + .../AuthorizationSnapshotServiceTest.java | 2 + .../PlatformUserManagementServiceTest.java | 18 ++++ .../baiyue/lastleg/entity/LastlegOrder.java | 2 + .../inboundplan/entity/InboundPlan.java | 1 + .../inboundplan/mapper/InboundPlanMapper.java | 7 ++ .../ReceivingContainerController.java | 38 +++++--- .../pda/dto/ReceivingContainerResponses.java | 13 ++- .../pda/entity/ReceivingContainerSession.java | 2 + .../gateway/HttpUnloadPhotoFileGateway.java | 36 +++++-- .../gateway/HttpWarehouseResourceGateway.java | 30 ++++++ .../pda/gateway/WarehouseResourceGateway.java | 6 ++ .../service/ReceivingContainerService.java | 94 ++++++++++++++++--- .../controller/InventoryQueryController.java | 29 +++--- .../query/mapper/InventoryQueryMapper.java | 2 - .../lastleg/mapper/LastlegOrderMapper.java | 7 ++ .../service/LoadingCompletedConsumer.java | 3 +- .../src/main/resources/db/lastleg.sql | 7 +- .../openapi/overseas-business-v1.yaml | 6 +- .../pda/ReceivingContainerContractTest.java | 6 ++ .../ReceivingContainerControllerTest.java | 14 ++- .../HttpUnloadPhotoFileGatewayTest.java | 56 +++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 39 ++++++-- .../InventorySchemaContractTest.java | 13 +++ .../InventoryQueryControllerTest.java | 14 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/admin-test-env-acceptance.mjs | 19 +++- .../manual-state-matrix-contract.test.mjs | 8 ++ .../phase3-loading-marker-contract.test.mjs | 9 ++ .../admin-test-env-modules/phase3-loading.mjs | 19 +++- .../phase3-producer-chain-contract.test.mjs | 6 +- .../phase3-warehouse-rbac.mjs | 5 +- .../phase4-carrier-nodes-contract.test.mjs | 13 +++ .../phase4-carrier-nodes.mjs | 3 +- ...ase4-cross-region-events-contract.test.mjs | 6 ++ .../phase4-overseas-pda-contract.test.mjs | 11 ++- .../phase4-overseas-pda.mjs | 64 ++++++++++--- ...ternalWarehouseResourceLockController.java | 12 +++ .../PlatformWarehouseController.java | 4 +- .../dto/SharedWarehouseReference.java | 4 + .../warehouse/dto/WarehouseRequests.java | 4 +- .../service/WarehouseApplicationService.java | 26 +++++ .../PlatformWarehouseControllerTest.java | 16 ++++ .../WarehouseApplicationServiceTest.java | 39 ++++++++ 61 files changed, 774 insertions(+), 118 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 47bc2000a..1061534be 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -21,4 +21,12 @@ public class AuthorizationSnapshot { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index ec283c23e..f7cbb1ff4 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -111,7 +111,7 @@ public class AuthService { Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) @@ -127,8 +127,11 @@ public class AuthService { .claim("userId", account.getId()) .claim("tenantId", account.getDefaultTenantId()) .claim("appCode", account.getDefaultAppCode()) - .claim("permissionVersion", account.getTokenVersion()) - .build(); + .claim("permissionVersion", account.getTokenVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from( signingHeader(), claims)).getTokenValue(); 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 8e2945c5f..915604787 100644 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -51,7 +51,7 @@ public class OverseasTokenService { public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { Instant issuedAt = Instant.now(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet claims = JwtClaimsSet.builder() + JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() .issuer(properties.issuer()).audience(List.of(properties.audience())) .subject(account.getId().toString()).id(UUID.randomUUID().toString()) .issuedAt(issuedAt).expiresAt(expiresAt) @@ -60,8 +60,11 @@ public class OverseasTokenService { .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) .claim("roles", authorization.getRoles()) .claim("authorities", overseasAuthorities(authorization.getPermissions())) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()) - .build(); + .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); + if (authorization.getOverseasWarehouseId() != null) { + claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); + } + JwtClaimsSet claims = claimsBuilder.build(); String token = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds()); 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 aed82fc6f..562d09705 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -60,7 +60,7 @@ class AuthServiceSmallJwtTest { account.setDefaultAppCode("PLATFORM"); account.setTokenVersion(1); AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", - "WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L); + "WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(repository.findByUsername("operator")).thenReturn(account); @@ -82,12 +82,13 @@ class AuthServiceSmallJwtTest { .containsEntry("appCode", "PLATFORM") .containsEntry("permissionVersion", 1) .containsEntry("membership_id", 84L) + .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), - org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), + org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); 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 20fd26464..2141df038 100644 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -51,7 +51,7 @@ class OverseasTokenServiceTest { String lastlegAuthority = "lastleg:pda:operate"; String domesticAuthority = "platform:firstleg:order:list"; AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("PLATFORM_ADMIN"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L); + Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L); Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(accounts.findById(42L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); @@ -71,7 +71,8 @@ class OverseasTokenServiceTest { .containsEntry("membership_id", 84L) .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") .containsEntry("permission_version", 7L) - .containsEntry("roles", Set.of("PLATFORM_ADMIN")) + .containsEntry("overseas_warehouse_id", 900L) + .containsEntry("roles", Set.of("WAREHOUSE_PICKER")) .containsEntry("authorities", Set.of(authority, lastlegAuthority)) .doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion"); assertThat(response.expiresIn()).isEqualTo(900); diff --git a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java index 1f4927303..1391d548a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java +++ b/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java @@ -9,11 +9,19 @@ import java.util.Set; public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions, boolean impersonated, Long actorUserId, - Long actorMembershipId, boolean passwordChangeRequired) implements Principal { + Long actorMembershipId, boolean passwordChangeRequired, + Long overseasWarehouseId) implements Principal { public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, String username, String appCode, String clientType, Set permissions) { this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, - permissions, false, null, null, false); + permissions, false, null, null, false, null); + } + public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId, + String username, String appCode, String clientType, Set permissions, + boolean impersonated, Long actorUserId, Long actorMembershipId, + boolean passwordChangeRequired) { + this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType, + permissions, impersonated, actorUserId, actorMembershipId, passwordChangeRequired, null); } public AuthenticatedUser { permissions = permissions == null ? Set.of() : Set.copyOf(permissions); diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 84faded6a..bd5a37ced 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -43,7 +43,8 @@ public class JwtClaimsMapper { Boolean.TRUE.equals(jwt.getClaim("impersonated")), longClaim(jwt, "actor_user_id"), longClaim(jwt, "actor_membership_id"), - Boolean.TRUE.equals(jwt.getClaim("password_change_required"))); + Boolean.TRUE.equals(jwt.getClaim("password_change_required")), + longClaim(jwt, "overseas_warehouse_id")); List authorities = permissions.stream() .map(SimpleGrantedAuthority::new).toList(); return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities); diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 3189cd48a..ce04f51be 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -81,6 +81,7 @@ class JwtClaimsMapperTest { .claim("actor_user_id", 42L) .claim("actor_membership_id", 43L) .claim("password_change_required", true) + .claim("overseas_warehouse_id", 900L) .build(); AuthenticatedUser user = new JwtClaimsMapper().map(jwt).getPrincipal(); @@ -88,5 +89,6 @@ class JwtClaimsMapperTest { assertThat(user.actorUserId()).isEqualTo(42L); assertThat(user.actorMembershipId()).isEqualTo(43L); assertThat(user.passwordChangeRequired()).isTrue(); + assertThat(user.overseasWarehouseId()).isEqualTo(900L); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9e282cfd7..08bbccb59 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -403,6 +403,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', @@ -417,6 +418,13 @@ CREATE TABLE IF NOT EXISTS iam_membership ( KEY idx_membership_tenant (tenant_id, status) ) ENGINE=InnoDB COMMENT='客户成员关系表:表示用户在客户企业中的成员身份,是分配企业客户端角色的主体'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='iam_membership' AND column_name='overseas_warehouse_id'), + 'SELECT 1', 'ALTER TABLE iam_membership ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外仓PDA操作员绑定的唯一实际海外仓ID'' AFTER member_no')); +PREPARE iam_membership_overseas_warehouse_stmt FROM @ddl; +EXECUTE iam_membership_overseas_warehouse_stmt; +DEALLOCATE PREPARE iam_membership_overseas_warehouse_stmt; + CREATE TABLE IF NOT EXISTS iam_role ( id BIGINT NOT NULL COMMENT '角色ID', tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色作用域:0为平台预置模板,正数为客户ID和数据隔离键', diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index c02a02b7f..772b25e1e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -386,8 +386,40 @@ UPDATE ll_inbound_plan_line l JOIN ll_order o ON o.id=l.order_id SET l.destination_type=COALESCE(NULLIF(o.destination_type,''),CASE WHEN NULLIF(l.fba_warehouse_code,'') IS NULL THEN 'OVERSEAS' ELSE 'FBA' END) WHERE l.destination_type IS NULL OR l.destination_type='' OR l.destination_type='OVERSEAS'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓ID快照'' AFTER customer_name')); +PREPARE phase4_source_warehouse_id_stmt FROM @ddl; EXECUTE phase4_source_warehouse_id_stmt; DEALLOCATE PREPARE phase4_source_warehouse_id_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id')); +PREPARE phase4_source_warehouse_name_stmt FROM @ddl; EXECUTE phase4_source_warehouse_name_stmt; DEALLOCATE PREPARE phase4_source_warehouse_name_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_inbound_plan ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''海外实际收货共享物理仓ID,由PDA认领柜号时冻结'' AFTER container_no')); +PREPARE phase4_plan_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_plan_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_plan_receiving_warehouse_stmt; + +UPDATE ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id +SET o.source_warehouse_id=COALESCE(o.source_warehouse_id,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))), + o.source_warehouse_name=COALESCE(o.source_warehouse_name,JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseName'))), + o.overseas_warehouse_id=NULL,o.overseas_warehouse_name=NULL +WHERE o.current_status<>'INBOUND_COMPLETED' + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId')); + +DROP PROCEDURE IF EXISTS assert_pending_orders_do_not_use_source_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_pending_orders_do_not_use_source_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_order o JOIN ll_loading_event_inbox i ON i.event_id=o.source_event_id + WHERE o.current_status<>'INBOUND_COMPLETED' AND o.overseas_warehouse_id IS NOT NULL + AND o.overseas_warehouse_id=JSON_UNQUOTE(JSON_EXTRACT(i.payload_json,'$.warehouseId'))) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='未完成尾程单仍将国内来源仓冒充海外实际收货仓'; + END IF; +END$$ +DELIMITER ; +CALL assert_pending_orders_do_not_use_source_warehouse(); +DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; + CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间', @@ -397,6 +429,29 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); +PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_name'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''仓库服务校验后的海外实际收货共享物理仓名称快照'' AFTER overseas_warehouse_id')); +PREPARE phase4_session_receiving_warehouse_name_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_name_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_name_stmt; + +DROP PROCEDURE IF EXISTS assert_active_receiving_session_has_warehouse; +DELIMITER $$ +CREATE PROCEDURE assert_active_receiving_session_has_warehouse() +BEGIN + IF EXISTS(SELECT 1 FROM ll_receiving_container_session WHERE active_container_key IS NOT NULL + AND (overseas_warehouse_id IS NULL OR overseas_warehouse_name IS NULL OR overseas_warehouse_name='')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='存在无法安全推断海外实际收货仓的未完成PDA柜会话'; + END IF; +END$$ +DELIMITER ; +CALL assert_active_receiving_session_has_warehouse(); +DROP PROCEDURE assert_active_receiving_session_has_warehouse; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='sign_photos'), 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表''', 'ALTER TABLE ll_receiving_container_session ADD COLUMN sign_photos JSON NULL COMMENT ''签收卸柜照片角色与文件ID列表'' AFTER status')); diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 6867ebf88..acc77d13d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -40,5 +40,13 @@ public final class AuthorizationDtos { private Set roles; private Set permissions; private Long permissionVersion; + private Long overseasWarehouseId; + + public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, + Long permissionVersion) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, null); + } } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java index 7c28c5dd7..79dae468b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java @@ -20,16 +20,28 @@ public final class PlatformUserManagementDtos { @Email @Size(max = 128) String email, @NotNull Long tenantId, @NotNull List roleIds, + Long overseasWarehouseId, @NotBlank @Size(min = 8, max = 64) @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password, - @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } + @NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { + public CreateUserRequest(String username, String displayName, String phone, String email, + Long tenantId, List roleIds, String password, String status) { + this(username, displayName, phone, email, tenantId, roleIds, null, password, status); + } + } public record UpdateUserRequest( @NotBlank @Size(max = 64) String displayName, @NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone, @Email @Size(max = 128) String email, @NotNull Long tenantId, - @NotNull List roleIds) { } + @NotNull List roleIds, + Long overseasWarehouseId) { + public UpdateUserRequest(String displayName, String phone, String email, + Long tenantId, List roleIds) { + this(displayName, phone, email, tenantId, roleIds, null); + } + } public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { } public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64) @@ -40,7 +52,7 @@ public final class PlatformUserManagementDtos { String displayName, String phone, String email, Long tenantId, String tenantName, String memberType, String memberNo, String status, List roleIds, List roleNames, LocalDateTime lastLoginAt, - boolean protectedAccount) { } + boolean protectedAccount, Long overseasWarehouseId) { } public record TenantOption(Long id, String name, String status) { } public record RoleOption(Long id, Long tenantId, String name, String status) { } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java index 1b045d0ac..dfdaa658a 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java @@ -9,6 +9,6 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus. @Getter @Setter @TableName("iam_membership") public class IamMembership extends BaseEntity { - @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; + @TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private Long overseasWarehouseId; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt; } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index f38fff466..6d57e9632 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -65,4 +65,8 @@ public interface AuthorizationQueryMapper { @Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}") long findPermissionVersion(@Param("userId") long userId); + + @Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0") + Long findOverseasWarehouseId(@Param("tenantId") long tenantId, + @Param("membershipId") long membershipId); } diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index 1592dbcf0..df90f8b1e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -47,7 +47,8 @@ public class AuthorizationSnapshotService { request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode)); return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions), - mapper.findPermissionVersion(request.getUserId())); + mapper.findPermissionVersion(request.getUserId()), + mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 73a8e7fe9..296100ffb 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -75,6 +75,7 @@ public class PlatformUserManagementService { long tenantId = request.tenantId(); requirePlatformTenant(tenantId); List roleIds = validateRoles(tenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); long authUserId = IdWorker.getId(); long userId = IdWorker.getId(); long membershipId = IdWorker.getId(); @@ -99,6 +100,7 @@ public class PlatformUserManagementService { membership.setUserId(userId); membership.setMemberType("EMPLOYEE"); membership.setMemberNo(memberNo(tenantId, membershipId)); + membership.setOverseasWarehouseId(overseasWarehouseId); membership.setStatus(request.status()); membership.setJoinedAt(LocalDateTime.now()); membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); @@ -121,6 +123,7 @@ public class PlatformUserManagementService { long newTenantId = request.tenantId(); requirePlatformTenant(newTenantId); List roleIds = validateRoles(newTenantId, request.roleIds()); + Long overseasWarehouseId = validateWarehouseBinding(roleIds, request.overseasWarehouseId()); List oldRoleIds = members.findRoleIds(oldTenantId, membershipId); if (isProtectedPlatformAdmin(membership, oldRoleIds) && (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) { @@ -134,6 +137,7 @@ public class PlatformUserManagementService { boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); + boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -145,6 +149,7 @@ public class PlatformUserManagementService { user.setEmail(trimToNull(request.email())); user.setUserType(userType(newTenantId)); membership.setTenantId(newTenantId); + membership.setOverseasWarehouseId(overseasWarehouseId); if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) { membership.setMemberNo(memberNo(newTenantId, membershipId)); } @@ -152,7 +157,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if (rolesChanged && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } @@ -222,7 +227,8 @@ public class PlatformUserManagementService { return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(), user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName, membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds, - roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds)); + roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds), + membership.getOverseasWarehouseId()); } private List platformRoles() { @@ -245,6 +251,18 @@ public class PlatformUserManagementService { return roleIds; } + private Long validateWarehouseBinding(List roleIds, Long overseasWarehouseId) { + boolean picker = roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull) + .anyMatch(role -> "WAREHOUSE_PICKER".equals(role.getRoleCode())); + if (picker && (overseasWarehouseId == null || overseasWarehouseId <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "海外仓拣货员必须绑定一个有效海外仓"); + } + if (!picker && overseasWarehouseId != null) { + throw new BizException(ResultCode.BAD_REQUEST, "仅海外仓拣货员可绑定海外仓"); + } + return overseasWarehouseId; + } + private boolean isProtectedPlatformAdmin(IamMembership membership, List roleIds) { return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds); } diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index 65b0d2654..c2dfaffb3 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -114,6 +114,7 @@ CREATE TABLE IF NOT EXISTS iam_membership ( user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID', member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型:EMPLOYEE企业员工等', member_no VARCHAR(64) NULL COMMENT '客户企业内部成员编号', + overseas_warehouse_id BIGINT NULL COMMENT '海外仓PDA操作员绑定的唯一实际海外仓ID', status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态:ACTIVE在职、DISABLED停用、LEFT已离开', joined_at DATETIME(3) NULL COMMENT '加入客户企业时间', left_at DATETIME(3) NULL COMMENT '离开客户企业时间', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 5f5b22b77..fe88bdc06 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -1279,6 +1279,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true} status: {type: string, enum: [ACTIVE, DISABLED]} UpdatePlatformManagedUserRequest: @@ -1290,6 +1291,7 @@ components: email: {type: string, format: email, maxLength: 128} tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0} roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}} + overseasWarehouseId: {type: integer, format: int64, nullable: true, description: 选择WAREHOUSE_PICKER角色时必填且只能绑定一个实际海外仓;其他角色必须为空} CreateInternalUserRequest: type: object required: [displayName] @@ -1362,6 +1364,7 @@ components: roleNames: {type: array, items: {type: string}} lastLoginAt: {type: string, format: date-time, nullable: true} protectedAccount: {type: boolean} + overseasWarehouseId: {type: string, pattern: '^\d+$', nullable: true, description: 海外仓PDA操作员绑定的唯一实际海外仓ID} CreateMemberRequest: type: object required: [username, password, displayName, memberType] diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index 550066f8d..eeab42b78 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -29,6 +29,7 @@ class AuthorizationSnapshotServiceTest { .thenReturn(List.of("ADMIN", "OPERATOR")); when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")) .thenReturn(List.of("order:read", "order:create")); + when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L); var result = service .load(new SnapshotRequest(42L, 7L, 9L, "merchant")); @@ -37,6 +38,7 @@ class AuthorizationSnapshotServiceTest { assertThat(result.getClientType()).isEqualTo("MERCHANT"); assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR"); assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create"); + assertThat(result.getOverseasWarehouseId()).isEqualTo(900L); } @Test 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 42df71a34..8f45baf95 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -57,6 +57,7 @@ class PlatformUserManagementServiceTest { when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE", LocalDateTime.of(2026, 6, 1, 10, 30), 0L, 30L, "PLATFORM"))); + when(auth.revokeSessions(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); } @Test @@ -117,6 +118,23 @@ class PlatformUserManagementServiceTest { verify(auth, never()).scope(anyString(), anyLong(), any()); } + @Test + void warehousePickerRequiresAndPersistsSingleOverseasWarehouseBinding() { + platformRole.setRoleCode("WAREHOUSE_PICKER"); + UpdateUserRequest missing = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), null); + assertThrows(BizException.class, + () -> service.update(30L, missing, Set.of("platform:user:grant-role"))); + + UpdateUserRequest bound = new UpdateUserRequest("张伟", "13800138000", null, + 0L, List.of(40001L), 900L); + UserResponse response = service.update(30L, bound, Set.of("platform:user:grant-role")); + + assertEquals(900L, membership.getOverseasWarehouseId()); + assertEquals(900L, response.overseasWarehouseId()); + verify(auth).revokeSessions(eq("internal-token"), any()); + } + @Test void currentPlatformOperatorCannotDisableSelf() { assertThrows(BizException.class, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 57229f0e4..89234d307 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -24,6 +24,8 @@ public class LastlegOrder { private String sourceOrderNo; private String rootOrderId; private String customerName; + private String sourceWarehouseId; + private String sourceWarehouseName; private String overseasWarehouseId; private String overseasWarehouseName; private String fbaWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index ec6ccd5ad..79ffac343 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -17,6 +17,7 @@ public class InboundPlan { private String customerId; private Long sourceOrderId; private String containerNo; + private Long overseasWarehouseId; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 68bc44b2c..534969f23 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; @@ -47,4 +48,10 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + + "WHERE id=#{planId} AND customer_id=#{customerId} " + + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") + int claimReceivingWarehouse(@Param("customerId") String customerId, @Param("planId") long planId, + @Param("warehouseId") long warehouseId); } 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 ce144e37c..6adcf7ff5 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 @@ -38,7 +38,7 @@ public class ReceivingContainerController { public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { Identity identity = identity(user); - return ApiResponse.success(service.candidates(identity.operatorKey())); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } @GetMapping("/{containerNo}") @@ -46,21 +46,21 @@ public class ReceivingContainerController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { Identity identity = identity(user); - return ApiResponse.success(service.detail(identity.operatorKey(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { Identity identity = identity(user); - return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), + return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), containerNo, request)); } @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @@ -68,13 +68,13 @@ public class ReceivingContainerController { @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.current(identity.operatorKey())); } @@ -82,7 +82,7 @@ public class ReceivingContainerController { public ApiResponse resolveCarton( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @@ -90,7 +90,7 @@ public class ReceivingContainerController { public ApiResponse resolveSku( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @@ -98,28 +98,28 @@ public class ReceivingContainerController { public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = identity(user); + Identity identity = currentIdentity(user); return ApiResponse.success(service.complete(identity.operatorKey(), request)); } @@ -131,10 +131,20 @@ public class ReceivingContainerController { if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse"); + } String operator = String.valueOf(user.userId()); return new Identity(operator, - user.username() == null || user.username().isBlank() ? operator : user.username()); + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); } - private record Identity(String operatorKey, String operatorName) { } + private Identity currentIdentity(AuthenticatedUser user) { + Identity identity = identity(user); + service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); + return identity; + } + + private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 385113cf1..656c83a1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -10,7 +10,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } - public record Session(String containerNo, String status, String operatorName, int version, + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, + String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime completedAt, int totalPackages, int receivedPackages, int pendingPackages, @@ -18,19 +19,23 @@ public final class ReceivingContainerResponses { public record SignedPhoto(ReceivingPhotoRole role, long fileId) { } - public record Inbound(String inboundCode, String destinationType, String destinationWarehouseId, + public record Inbound(String inboundCode, String destinationType, + String overseasWarehouseId, String overseasWarehouseName, + String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record CartonResolved(String cartonMark, String skuCode, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } public record SkuResolved(String skuCode, String productName, String inboundCode, - String destinationType, String destinationWarehouseId, + String destinationType, String overseasWarehouseId, + String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 68a074b0b..2667cb427 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -15,6 +15,8 @@ public class ReceivingContainerSession { private Long id; private String customerId; private String containerNo; + private Long overseasWarehouseId; + private String overseasWarehouseName; private String operatorKey; private String operatorName; private String activeOperatorKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index debcb721b..3195adf30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -4,12 +4,14 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.core.io.ByteArrayResource; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import java.io.IOException; @@ -42,10 +44,14 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo upload failed"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo upload failed"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo upload failed"); Metadata data = response == null ? null : response.data(); - if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata"); + if (data == null || data.fileId() == null) throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "File service returned no photo metadata"); return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes()); } @@ -58,22 +64,40 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { tenantId, BUSINESS_TYPE, taskId, fileId) .header("X-Internal-Service-Token", internalToken) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { - throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Unload photo does not exist or its ownership is invalid"); }).body(MetadataEnvelope.class); + requireSuccess(response, "Unload photo does not exist or its ownership is invalid"); Metadata data = response == null ? null : response.data(); if (data == null || data.fileId() == null || data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { - throw new IllegalArgumentException("Unload photo metadata does not match the PDA task"); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Unload photo metadata does not match the PDA task"); } } private long tenantId(String customerId) { try { return Long.parseLong(customerId); } - catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + catch (NumberFormatException exception) { throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Customer ID must be numeric", exception); } } - private record MetadataEnvelope(Metadata data) {} + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) {} private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) {} } 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 c28a0ab89..62c41435f 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 @@ -2,8 +2,10 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -111,9 +113,37 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { response.data().fbaWarehouseCode(), response.data().version()); } + @Override + public WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + WarehouseReferenceResponse response = client.get() + .uri("/internal/v1/warehouse-resource-locks/shared-warehouses/{warehouseId}", warehouseId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().value() == 404 + ? HttpStatus.NOT_FOUND : (result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), + "Shared overseas warehouse lookup failed"); + }).body(WarehouseReferenceResponse.class); + if (response == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Shared overseas warehouse service returned no response"); + } + if ("A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Active shared overseas warehouse was not found"); + } + if (!"200".equals(response.code()) || response.data() == null + || response.data().warehouseId() != warehouseId || !"ACTIVE".equals(response.data().status())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Invalid shared overseas warehouse binding"); + } + return response.data(); + } + private record WarehousePalletResponse(WarehousePalletData data) {} private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} + private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} } 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 0350073c4..1d6b66f96 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,9 @@ public interface WarehouseResourceGateway { void scrap(String customerId, long resourceLockId); void scrapPallet(String customerId, long palletId); WarehousePalletReference resolvePallet(String palletCode); + default WarehouseReference requireActiveSharedWarehouse(long warehouseId) { + throw new UnsupportedOperationException("Shared warehouse resolution is not configured"); + } record WarehousePalletReference(long palletId, String palletNo, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) { @@ -15,4 +18,7 @@ public interface WarehouseResourceGateway { this(palletId, palletNo, 0L, locationId, null, null, 0); } } + + record WarehouseReference(long warehouseId, String warehouseCode, String warehouseName, + String status) { } } 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 1f616904a..80710f77c 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 @@ -80,8 +80,9 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } - public List candidates(String operatorKey) { + public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); Map> grouped = new LinkedHashMap<>(); plans.selectList(new LambdaQueryWrapper() .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { @@ -89,7 +90,7 @@ public class ReceivingContainerService { grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); } }); - return grouped.entrySet().stream().map(entry -> { + return grouped.entrySet().stream().filter(entry -> eligible(entry.getValue(), overseasWarehouseId)).map(entry -> { requireSingleCustomer(entry.getValue(), entry.getKey()); Totals totals = totals(entry.getValue()); boolean active = current != null && entry.getKey().equals(current.getContainerNo()); @@ -99,27 +100,39 @@ public class ReceivingContainerService { }).toList(); } - public ReceivingContainerResponses.Session detail(String operatorKey, String containerNo) { + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw notFound("Container is assigned to another overseas warehouse"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + requireSessionWarehouse(current, overseasWarehouseId); if (current != null && !containerNo.equals(current.getContainerNo())) { throw conflict("Current account is locked to another selected container"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - customerId, operatorKey, containerNo), containerPlans); + customerId, operatorKey, receivingWarehouse, containerNo), containerPlans); } @Transactional - public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, String containerNo, + public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); - if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + if (replay != null) { + ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); + if (!Objects.equals(result.overseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Prepared container belongs to another overseas warehouse"); + } + return result; + } List containerPlans = requirePlans(containerNo); String customerId = requireSingleCustomer(containerPlans, containerNo); + if (!eligible(containerPlans, overseasWarehouseId)) throw conflict("Container is assigned to another overseas warehouse"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); + requireSessionWarehouse(active, overseasWarehouseId); if (active != null) { if (!containerNo.equals(active.getContainerNo())) throw conflict("Finish the current container before selecting another one"); ReceivingContainerResponses.Session result = snapshot(active, containerPlans); @@ -128,9 +141,14 @@ public class ReceivingContainerService { if (sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { throw conflict("Container is already signed or prepared by another account"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + claimReceivingWarehouse(customerId, containerPlans, receivingWarehouse); LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setOverseasWarehouseId(overseasWarehouseId); + session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); session.setActiveOperatorKey(activeOperatorKey); session.setActiveContainerKey(activeContainerKey(containerNo)); session.setStatus("WAIT_SIGN"); session.setPreparedAt(now); session.setCreatedAt(now); session.setUpdatedAt(now); @@ -175,6 +193,10 @@ public class ReceivingContainerService { return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo())); } + public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) { + requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId); + } + public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); @@ -190,7 +212,8 @@ public class ReceivingContainerService { } ReceivingContainerResponses.Inbound inbound = inbound(ref); return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), - inbound.inboundCode(), inbound.destinationType(), inbound.destinationWarehouseId(), + inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), + inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } @@ -403,7 +426,8 @@ public class ReceivingContainerService { long actualWarehouse; try { actualWarehouse = Long.parseLong(ref.order().getOverseasWarehouseId()); } catch (Exception exception) { throw conflict("Inbound order has no valid receiving warehouse ID"); } - if (pallet.warehouseId() != actualWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); + long assignedWarehouse = ref.plan().getOverseasWarehouseId() == null ? actualWarehouse : ref.plan().getOverseasWarehouseId(); + if (pallet.warehouseId() != assignedWarehouse) throw conflict("Pallet belongs to another overseas warehouse"); String type = destinationType(ref.line(), ref.order()); if ("FBA".equals(type)) { if (ref.line().getFbaWarehouseCode() == null || ref.line().getFbaWarehouseCode().isBlank() @@ -438,7 +462,8 @@ public class ReceivingContainerService { .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); - return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getStatus(), session.getOperatorName(), + return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), + session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), value(session.getVersion()), signPhotos(session), session.getPreparedAt(), session.getSignedAt(), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), inbounds); } @@ -451,6 +476,8 @@ public class ReceivingContainerService { .mapToInt(value -> value(value.getPickedPackageCount())).sum(); int total = value(ref.line().getRequiredPackageCount()); return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), + ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() + : String.valueOf(ref.plan().getOverseasWarehouseId()), ref.order().getOverseasWarehouseName(), ref.line().getDestinationWarehouseId(), ref.line().getDestinationWarehouseCode(), ref.line().getDestinationWarehouseName(), ref.line().getFbaWarehouseCode(), value(ref.line().getPlannedPalletCount()), total, received, Math.max(0, total - received)); @@ -517,7 +544,8 @@ public class ReceivingContainerService { ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), - inbound.destinationType(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), + inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), + inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); } @@ -600,12 +628,56 @@ public class ReceivingContainerService { if (sessions.updateById(session) != 1) throw conflict("Receiving container session changed concurrently"); } - private ReceivingContainerSession waitingView(String customerId, String operatorKey, String containerNo) { + private ReceivingContainerSession waitingView(String customerId, String operatorKey, + WarehouseResourceGateway.WarehouseReference warehouse, + String containerNo) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setCustomerId(customerId); + value.setOverseasWarehouseId(warehouse.warehouseId()); + value.setOverseasWarehouseName(warehouse.warehouseName()); value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); return value; } + private boolean eligible(List containerPlans, long overseasWarehouseId) { + Set assigned = containerPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (assigned.size() > 1 || (!assigned.isEmpty() && containerPlans.stream() + .anyMatch(plan -> plan.getOverseasWarehouseId() == null))) { + throw conflict("Container plans have inconsistent receiving warehouse assignments"); + } + return assigned.isEmpty() || assigned.contains(overseasWarehouseId); + } + + private void claimReceivingWarehouse(String customerId, List containerPlans, + WarehouseResourceGateway.WarehouseReference warehouse) { + long overseasWarehouseId = warehouse.warehouseId(); + String warehouseId = String.valueOf(overseasWarehouseId); + for (InboundPlan plan : containerPlans) { + int claimed = plans.claimReceivingWarehouse(customerId, plan.getId(), overseasWarehouseId); + InboundPlan actual = plans.selectById(plan.getId()); + if ((claimed == 0 && actual == null) || actual == null + || !Objects.equals(actual.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Container was claimed by another overseas warehouse"); + } + plan.setOverseasWarehouseId(overseasWarehouseId); + for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + orders.claimReceivingWarehouse(customerId, line.getOrderId(), warehouseId, + warehouse.warehouseName()); + LastlegOrder order = orders.selectById(line.getOrderId()); + if (order == null || !warehouseId.equals(order.getOverseasWarehouseId())) { + throw conflict("Inbound order was claimed by another overseas warehouse"); + } + } + } + } + + private void requireSessionWarehouse(ReceivingContainerSession session, long overseasWarehouseId) { + if (session != null && !Objects.equals(session.getOverseasWarehouseId(), overseasWarehouseId)) { + throw conflict("Current container belongs to another overseas warehouse"); + } + } + private ReceivingContainerOperation replay(String operatorKey, String action, String key, Object request) { key = idempotencyKey(key); ReceivingContainerOperation value = operations.find(operatorKey, key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index f34f676e2..87226951e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -22,6 +22,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -40,9 +41,9 @@ public class InventoryQueryController { @Operation(summary = "Get inbound-order package inventory totals", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse summary( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.dashboard(null, query)); + return ApiResponse.success(service.dashboard(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances") @@ -50,9 +51,9 @@ public class InventoryQueryController { @Operation(summary = "Page inventory by customer, warehouse and inbound order", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> balances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return ApiResponse.success(service.balances(null, query)); + return ApiResponse.success(service.balances(platformScope(user, customerId), query)); } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @@ -61,9 +62,9 @@ public class InventoryQueryController { public ApiResponse> positions( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long balanceId, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.PositionQuery query) { - requirePlatform(user); - return ApiResponse.success(service.positions(null, balanceId, query)); + return ApiResponse.success(service.positions(platformScope(user, customerId), balanceId, query)); } @GetMapping(BASE_PATH + "/ledger") @@ -71,9 +72,9 @@ public class InventoryQueryController { @Operation(summary = "Page append-only inventory movements", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST) public ApiResponse> ledgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return ApiResponse.success(service.ledgers(null, query)); + return ApiResponse.success(service.ledgers(platformScope(user, customerId), query)); } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @@ -81,9 +82,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { - requirePlatform(user); - return csv(service.exportBalances(null, query), "inventory-balances.csv"); + return csv(service.exportBalances(platformScope(user, customerId), query), "inventory-balances.csv"); } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @@ -91,9 +92,9 @@ public class InventoryQueryController { @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { - requirePlatform(user); - return csv(service.exportLedgers(null, query), "inventory-ledger.csv"); + return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } private static ResponseEntity csv(byte[] body, String filename) { @@ -103,12 +104,14 @@ public class InventoryQueryController { .body(body); } - private void requirePlatform(AuthenticatedUser user) { + private String platformScope(AuthenticatedUser user, String requestedCustomerId) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); } + return requestedCustomerId == null || requestedCustomerId.isBlank() || "0".equals(requestedCustomerId.trim()) + ? null : requestedCustomerId.trim(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 316eb2b8d..16c85a001 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -89,8 +89,6 @@ public interface InventoryQueryMapper { @Select("')), }, token) - context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, + malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) const photos = [] for (const role of PHOTO_ROLES) { const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { @@ -265,7 +274,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { const pallet = await createPallet(context, inbound, index) if (!pallet) { blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, - `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + `无法在实际海外收货仓 ${inbound.overseasWarehouseId || 'missing'} 创建可解析真实托盘;目的仓 ${inbound.destinationWarehouseId || 'missing'} 不得代替物理仓`, 'warehouse-resource') continue } pallets.set(inbound.inboundCode, pallet) @@ -313,11 +322,21 @@ export async function run(context) { context.createdResources.phase4OverseasPda = { marker: context.marker, containerNo: null, customerId: null, palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], - cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + overseasWarehouseId: null, overseasWarehouseName: null, + cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } const merchantToken = await loginMerchantForNegativeProbe(context) - const primary = await createPicker(context, 1) - const secondary = primary?.token ? await createPicker(context, 2) : null + const pickerAccess = await resolvePickerAccess(context) + if (!pickerAccess) return + const sharedWarehouse = await createSharedWarehouse(context) + if (!sharedWarehouse) { + blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓') + return + } + context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id + context.createdResources.phase4OverseasPda.overseasWarehouseName = sharedWarehouse.name + const primary = await createPicker(context, 1, sharedWarehouse.id, pickerAccess) + const secondary = primary?.token ? await createPicker(context, 2, sharedWarehouse.id, pickerAccess) : null for (const picker of [primary, secondary]) if (picker?.membershipId) { context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) } @@ -332,3 +351,22 @@ export async function run(context) { await disablePicker(context, primary, 1) } } + +async function createSharedWarehouse(context) { + const suffix = markerSuffix(context) + const payload = { + customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50), + warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS', + countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance', + contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`, + status: 'ACTIVE', remark: `${context.marker}-shared-receiving`, + address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles', + district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' }, + capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }], + } + const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload) + const warehouseId = id(created?.body?.data) + context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created, + success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS) + return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null +} 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 1e9c74f77..13bac4df9 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 @@ -4,13 +4,16 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; import com.baiyue.warehouse.dto.WarehousePalletScrapResponse; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.WarehouseLocationEntity; import com.baiyue.warehouse.security.InternalServiceTokenGuard; import com.baiyue.warehouse.service.WarehouseResourceLockService; import com.baiyue.warehouse.service.WarehouseResourceService; +import com.baiyue.warehouse.service.WarehouseApplicationService; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; public class InternalWarehouseResourceLockController { @Resource private WarehouseResourceLockService service; @Resource private WarehouseResourceService resourceService; + @Resource private WarehouseApplicationService warehouses; @Resource private InternalServiceTokenGuard tokenGuard; @PostMapping @@ -67,4 +71,12 @@ public class InternalWarehouseResourceLockController { return ApiResponse.success(resourceService.ensureDefaultVirtualLocation( request.customerId(), request.warehouseId())); } + + @GetMapping("/shared-warehouses/{warehouseId}") + public ApiResponse sharedWarehouse( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long warehouseId) { + tokenGuard.verify(token); + return ApiResponse.success(warehouses.activeSharedReference(warehouseId)); + } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index f934717af..e4b27dfbc 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -164,8 +164,8 @@ public class PlatformWarehouseController { } private static void requireTenant(Long customerId) { - if (customerId == null || customerId <= 0) { - throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + if (customerId == null || customerId < 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target customer scope is required / 必须指定有效客户或平台共享范围"); } } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java new file mode 100644 index 000000000..aa83148fd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/SharedWarehouseReference.java @@ -0,0 +1,4 @@ +package com.baiyue.warehouse.dto; + +public record SharedWarehouseReference(long warehouseId, String warehouseCode, + String warehouseName, String status) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java index 7da4dc669..d762b3a30 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -8,7 +8,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -96,7 +96,7 @@ public final class WarehouseRequests { @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") public record QueryRequest( @Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true) - @Positive Long customerId, + @PositiveOrZero Long customerId, @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") @Min(1) long page, @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 3abc3cb97..89b7ded73 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.WarehouseDetailResponse; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.dto.SharedWarehouseReference; import com.baiyue.warehouse.entity.ServiceCapabilityEntity; import com.baiyue.warehouse.entity.WarehouseAddressEntity; import com.baiyue.warehouse.entity.WarehouseEntity; @@ -36,6 +37,7 @@ public class WarehouseApplicationService { captureBefore = false ) public Long create(WarehouseRequests.SaveRequest request) { + validateSharedWarehouse(request); ensureCodeUnique(request.customerId(), request.warehouseCode(), null); WarehouseEntity warehouse = toEntity(request); repository.insertWarehouse(warehouse); @@ -72,6 +74,7 @@ public class WarehouseApplicationService { if (request.customerId() == null || customerId != request.customerId()) { throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); } + validateSharedWarehouse(request); WarehouseEntity old = owned(customerId, id); ensureCodeUnique(customerId, request.warehouseCode(), id); @@ -98,6 +101,29 @@ public class WarehouseApplicationService { return new WarehouseDetailResponse(warehouse, addresses, capabilities); } + public SharedWarehouseReference activeSharedReference(long warehouseId) { + WarehouseEntity warehouse = repository.findOwned(0L, warehouseId); + boolean receiving = warehouse != null && repository.findCapabilities(0L, warehouseId).stream() + .anyMatch(value -> "RECEIVING".equals(value.getCapabilityCode()) && Boolean.TRUE.equals(value.getEnabled())); + if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus()) + || !"OVERSEAS".equals(warehouse.getWarehouseType()) || !receiving) { + throw new BizException(ResultCode.NOT_FOUND, "Active shared overseas warehouse not found"); + } + return new SharedWarehouseReference(warehouse.getId(), warehouse.getWarehouseCode(), + warehouse.getWarehouseName(), warehouse.getStatus()); + } + + private void validateSharedWarehouse(WarehouseRequests.SaveRequest request) { + if (request.customerId() == null || request.customerId() != 0L) return; + boolean receiving = request.capabilities() != null && request.capabilities().stream() + .anyMatch(value -> "RECEIVING".equals(value.capabilityCode()) + && !Boolean.FALSE.equals(value.enabled())); + if (!"OVERSEAS".equals(request.warehouseType()) || !receiving) { + throw new BizException(ResultCode.BAD_REQUEST, + "Shared physical warehouse must be OVERSEAS with RECEIVING enabled"); + } + } + @Transactional @BusinessAudit( event = "WAREHOUSE_STATUS_CHANGE", diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 0e4dea80f..ac330716f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -61,6 +61,22 @@ class PlatformWarehouseControllerTest { assertTrue(operation.description().contains("customerId")); } + @Test + void platformCanCreateSharedWarehouseInCustomerZeroScope() { + WarehouseApplicationService service = mock(WarehouseApplicationService.class); + PlatformWarehouseController controller = new PlatformWarehouseController(); + ReflectionTestUtils.setField(controller, "service", service); + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "1 Shared Ave", null, null, null); + WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", + "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, + List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))); + when(service.create(request)).thenReturn(900L); + + assertEquals(900L, controller.create(platform(), request).data()); + verify(service).create(request); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java index 1bdcc2925..37699e071 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java @@ -3,6 +3,8 @@ package com.baiyue.warehouse.service; import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.entity.WarehouseEntity; import com.baiyue.warehouse.entity.WarehouseLocationEntity; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.repository.WarehouseRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,6 +17,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.verify; @@ -59,6 +62,31 @@ class WarehouseApplicationServiceTest { verify(repository).pageAll(query); } + @Test + void sharedReceivingWarehouseMustBeOverseasAndResolvesCanonicalReference() { + WarehouseRequests.SaveRequest shared = sharedRequest("OVERSEAS", true); + doAnswer(call -> { ((WarehouseEntity) call.getArgument(0)).setId(900L); return null; }) + .when(repository).insertWarehouse(any()); + + assertThat(service.create(shared)).isEqualTo(900L); + + WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(900L); warehouse.setCustomerId(0L); + warehouse.setWarehouseCode("SHARED-LAX"); warehouse.setWarehouseName("Shared LAX"); + warehouse.setWarehouseType("OVERSEAS"); warehouse.setStatus("ACTIVE"); + ServiceCapabilityEntity receiving = new ServiceCapabilityEntity(); + receiving.setCapabilityCode("RECEIVING"); receiving.setEnabled(true); + when(repository.findOwned(0L, 900L)).thenReturn(warehouse); + when(repository.findCapabilities(0L, 900L)).thenReturn(List.of(receiving)); + + assertThat(service.activeSharedReference(900L).warehouseName()).isEqualTo("Shared LAX"); + } + + @Test + void sharedWarehouseRejectsNonOverseasOrMissingReceivingCapability() { + assertThatThrownBy(() -> service.create(sharedRequest("FBA", true))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.create(sharedRequest("OVERSEAS", false))).isInstanceOf(BizException.class); + } + private static WarehouseRequests.SaveRequest request() { WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", @@ -67,4 +95,15 @@ class WarehouseApplicationServiceTest { "OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, List.of()); } + + private static WarehouseRequests.SaveRequest sharedRequest(String type, boolean receiving) { + WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest( + "WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", + "1 Logistics Ave", null, null, null); + List capabilities = receiving + ? List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)) + : List.of(); + return new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", type, + "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, capabilities); + } } From 3fbb7653f7291a8488f16c768f3180d78796c84e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:14:41 +0800 Subject: [PATCH 124/231] fix: complete phase4 receiving acceptance chain --- deploy/sql/海外区域统一增量-MySQL8.sql | 3 ++ .../pda/entity/ReceivingContainerSession.java | 4 ++ .../service/ReceivingContainerService.java | 14 +++++- .../pda/ReceivingContainerContractTest.java | 13 ++++++ ...ReceivingContainerServiceBehaviorTest.java | 3 +- scripts/admin-test-env-acceptance.mjs | 4 +- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 1 + .../phase4-carrier-nodes-contract.test.mjs | 3 +- .../phase4-overseas-pda.mjs | 44 ++++++++++++++----- ...hase4-overseas-warehouse-contract.test.mjs | 6 ++- .../phase4-overseas-warehouse.mjs | 5 ++- 12 files changed, 79 insertions(+), 22 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 772b25e1e..819ed1c5e 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -461,6 +461,9 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receiving_drop_raw_photos_stmt; DEALLOCATE PREPARE phase4_receiving_drop_raw_photos_stmt; DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable; +UPDATE ll_receiving_container_session +SET active_operator_key=NULL,active_container_key=NULL +WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL); DELIMITER $$ CREATE PROCEDURE assert_receiving_global_locks_migratable() BEGIN diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 2667cb427..388bcf420 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.inboundplan.pda.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.Version; @@ -19,7 +21,9 @@ public class ReceivingContainerSession { private String overseasWarehouseName; private String operatorKey; private String operatorName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeOperatorKey; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeContainerKey; private String status; private String signPhotos; 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 80710f77c..fd8ac7df0 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 @@ -201,10 +201,15 @@ public class ReceivingContainerService { ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = refs(customerId, session.getContainerNo()).stream() + InboundRef ref = currentRefs.stream() .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() .orElseThrow(() -> conflict("Carton mark belongs to another container")); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { @@ -285,10 +290,15 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); + List currentRefs = refs(customerId, session.getContainerNo()); + if (currentRefs.stream().noneMatch(value -> + "OVERSEAS".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA destination must use bulk count"); + } InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = refs(customerId, session.getContainerNo()).stream() + InboundRef ref = currentRefs.stream() .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() .orElseThrow(() -> conflict("Carton mark belongs to another container")); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { 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 566acf469..f90963626 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 @@ -1,7 +1,10 @@ package com.baiyue.lastleg.inboundplan.pda; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import jakarta.validation.constraints.Size; @@ -46,6 +49,15 @@ class ReceivingContainerContractTest { assertTrue("hasAuthority('lastleg:pda:operate')".equals(permission.value())); } + @Test + void completedSessionAlwaysPersistsClearedGlobalLockKeys() throws NoSuchFieldException { + for (String fieldName : List.of("activeOperatorKey", "activeContainerKey")) { + TableField field = ReceivingContainerSession.class.getDeclaredField(fieldName) + .getAnnotation(TableField.class); + assertTrue(field != null && field.updateStrategy() == FieldStrategy.ALWAYS, fieldName); + } + } + @Test void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); @@ -81,6 +93,7 @@ class ReceivingContainerContractTest { assertTrue(unified.contains("customer_id is null or a.customer_id=''")); assertTrue(unified.contains("signal sqlstate '45000'")); assertTrue(unified.contains("assert_receiving_global_locks_migratable")); + assertTrue(unified.contains("where status='completed' and (active_operator_key is not null or active_container_key is not null)")); assertTrue(unified.contains("concat('operator:',operator_key)")); assertTrue(unified.contains("concat('container:',upper(trim(container_no)))")); assertTrue(unified.contains("assert_receiving_operation_global_key_migratable")); 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 c4e470282..0241f0153 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 @@ -172,11 +172,10 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.reset(plans, lines, orders, sessions, operations, cartons, warehouse); stubCurrent("FBA", "CONT-A"); - InboundPlanCarton carton = carton(1L, "BOX-1"); - when(cartons.selectOne(any())).thenReturn(carton); assertThatThrownBy(() -> service.receiveCarton("7", "receiver", new ReceivingContainerRequests.CartonReceive("BOX-1", "PAL-1", "carton-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verifyNoInteractions(cartons); verifyNoInteractions(warehouse); } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7b752fbd2..450762dac 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -22,7 +22,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], ['phase3-customs', ['phase3-loading-finalize']], ['phase3-port-schedules', ['ocean-bookings']], - ['phase3-outbound-dispatch', ['phase3-customs']], + ['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']], ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], @@ -870,6 +870,7 @@ export class AcceptanceRun { tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, + boxCode: fixture.boxCode, customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -888,6 +889,7 @@ export class AcceptanceRun { tenantId: incrementalSource.tenantId, orderId: incrementalSource.orderId, inboundId: incrementalSource.inboundId, + boxCode: incrementalSource.boxCode, } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 0c7b0f567..e64e70553 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -365,6 +365,7 @@ export async function run(context) { containerBookingId, sourceOrderId: orderId, sourceInboundId: inboundId, + cartonMark: fixture.cartonMark || null, destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 80331208c..4280e8609 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1180,6 +1180,7 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, + cartonMark: source?.boxCode || source?.cartonMark || null, destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index da8d4a5c5..1fd31f443 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -29,7 +29,8 @@ test('focused carrier node execution injects the complete outbound-confirm produ const modules = normalizeModuleOrder(['phase4-carrier-nodes']) const required = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', - 'phase3-loading-finalize', 'phase3-customs', 'phase3-outbound-dispatch', + 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', + 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', 'phase3-carrier-schedule-queries', 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 94eaa6d78..18c01aa4b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -15,8 +15,8 @@ function conflictMessage(response, pattern) { return conflict(response) && patte function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) } function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) } function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } -function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } -function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } +function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } +function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) @@ -183,8 +183,8 @@ async function receiveFba(context, token, inbound, pallet) { return true } -async function receiveOverseas(context, token, inbound, pallet) { - const cartonMark = context.createdResources?.inboundMeasurement?.boxCode +async function receiveOverseas(context, token, inbound, pallet, producerCartonMark = null) { + const cartonMark = producerCartonMark || context.createdResources?.inboundMeasurement?.boxCode if (!nonBlank(cartonMark)) { blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') return false @@ -207,12 +207,12 @@ async function receiveOverseas(context, token, inbound, pallet) { return true } -async function runReceiving(context, token, secondaryToken, merchantToken) { - if (context.overseasToken) { +async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { + if (runAuthorizationProbes && context.overseasToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } - if (merchantToken) { + if (runAuthorizationProbes && merchantToken) { const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } @@ -229,8 +229,16 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) return } - const preferredContainer = context.createdResources?.phase3Loading?.containerNo - const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0] + const preferredContainer = id(preferredProducer?.containerNo) + || context.createdResources?.phase3Loading?.containerNo + const candidate = preferredContainer + ? candidates.data.find((item) => item.containerNo === preferredContainer) + : candidates.data[0] + if (!candidate) { + blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`, + '三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response) + return + } const containerNo = id(candidate?.containerNo) context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, @@ -300,7 +308,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbound, pallet) + await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { @@ -321,7 +329,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken) { export async function run(context) { context.createdResources.phase4OverseasPda = { marker: context.marker, containerNo: null, customerId: null, - palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], + palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], overseasWarehouseId: null, overseasWarehouseName: null, cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } @@ -345,8 +353,20 @@ export async function run(context) { blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') return } - await runReceiving(context, primary.token, secondary.token, merchantToken) + const matrix = context.createdResources?.phase4InboundProducerFixtures + const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + .filter((item) => nonBlank(item?.containerNo)) + for (const [index, producer] of (producers.length ? producers : [null]).entries()) { + context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` + context.createdResources.phase4OverseasPda.finalStatus = null + await runReceiving(context, primary.token, secondary.token, + index === 0 ? merchantToken : null, producer, index === 0) + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) + } + } } finally { + delete context.pdaContainerSuffix await disablePicker(context, secondary, 2) await disablePicker(context, primary, 1) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 10d20e210..261b0e84b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -42,7 +42,7 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/last-leg/orders/30001?')) return response({ order: { id: '30001' }, cargo: [], statusHistory: [] }) if (requestPath.startsWith('/api/platform/v1/last-leg/relabel-applications?')) return response(page([{ id: '40001', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }])) if (requestPath.includes('/relabel-applications/RL-1?')) return response({ application: { relabelNo: 'RL-1' }, items: [], history: [] }) - if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ id: '50001', sourceOrderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) + if (requestPath.startsWith('/api/platform/v1/last-leg/inbound-plans?')) return response(page([{ lineId: '50002', planId: '50001', orderId: '30001', planNo: 'IP-1', customerId: '10001', status: 'PENDING_ASSIGNMENT' }])) if (requestPath.includes('/inbound-plans/IP-1?')) return response({ planNo: 'IP-1', sourceOrderId: '30001', lines: [], pallets: [], allocations: [] }) if (requestPath.includes('/inventory/summary?')) return response({ inboundOrderCount: 1, palletCount: 1, totalPackages: 10, availablePackages: 10, holdPackages: 0, inTransitPackages: 0, receivingPackages: 0, procurementPackages: 0 }) if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) @@ -145,7 +145,9 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', for (const forbidden of ['/cartons', '/products', '/outbound', '/dispatch', '/delivery', '/pda', '/internal']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /requestId=/) assert.match(source, /traceId=/) - assert.match(source, /longId\(plan\.sourceOrderId\)/) + for (const field of ['lineId', 'planId', 'orderId']) { + assert.match(source, new RegExp(`longId\\(plan\\.${field}\\)`)) + } assert.match(source, /id\(pallet\.customerId\) === '0'/) assert.match(source, /创建本轮换标单/) assert.match(source, /换标单同幂等键不同请求返回A0409/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 3e977f3aa..7dc90a1b3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -107,8 +107,9 @@ export async function run(context) { const plan = records(plans.data)[0] if (plan) { context.assert(MODULE, '入仓计划关键字段正确', plans.response, - longId(plan.id) && longId(plan.sourceOrderId) && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), - `id=${id(plan.id) || 'missing'}, sourceOrderId=${id(plan.sourceOrderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) + longId(plan.lineId) && longId(plan.planId) && longId(plan.orderId) + && nonBlank(plan.planNo) && id(plan.customerId) === customerId && nonBlank(plan.status), + `lineId=${id(plan.lineId) || 'missing'}, planId=${id(plan.planId) || 'missing'}, orderId=${id(plan.orderId) || 'missing'}, planNo=${plan.planNo || 'missing'}, status=${plan.status || 'missing'}; ${trace(plans.response)}`, OPTIONS) const detail = await overseasGet(context, '查询入仓计划详情', query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(plan.planNo)}`, { customerId })) context.assert(MODULE, '入仓计划详情对应列表记录', detail.response, detail.data?.planNo === plan.planNo && longId(detail.data?.sourceOrderId) From 0e73a79479be2edc9f44234e0b9ec365c5adf848 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:32:37 +0800 Subject: [PATCH 125/231] fix: serialize phase4 inventory and outbound gates --- .../CustomsDispatchGateContributor.java | 13 ++++---- .../CustomsDispatchGateContributorTest.java | 17 +++++----- .../service/ReceivingContainerService.java | 3 +- .../service/LoadingCompletedConsumer.java | 3 +- .../pda/ReceivingContainerContractTest.java | 16 +++++++++ scripts/admin-test-env-acceptance.mjs | 2 +- ...ase4-cross-region-events-contract.test.mjs | 2 +- .../phase4-overseas-pda.mjs | 33 +++++++++++++++++-- 8 files changed, 69 insertions(+), 20 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 38897b09a..94eb4cc89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; import jakarta.annotation.Resource; @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component; @Component public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor { - @Resource private LoadingListRepository repository; + @Resource private CustomsRepository repository; @Override public String code() { return "CUSTOMS_CLEARANCE"; } @@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION"); + CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), + context.inboundId(), context.containerBookingId()); if (value == null) return gate(false, "MISSING"); - return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED"); + return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 594eaf569..6695ea5d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.entity.ContainerDocumentEntity; -import com.baiyue.firstleg.repository.LoadingListRepository; +import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; +import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; class CustomsDispatchGateContributorTest { - @Mock LoadingListRepository repository; + @Mock CustomsRepository repository; @InjectMocks CustomsDispatchGateContributor contributor; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @@ -22,15 +22,16 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCompletedSimpleDeclaration() { - ContainerDocumentEntity value = new ContainerDocumentEntity(); - when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value); + @Test void passesOnlyCanonicalClearanceCompletedCase() { + CustomsCaseEntity value = new CustomsCaseEntity(); + value.setStatus("RECEIPT_AVAILABLE"); + when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); - value.setCompletedAt(java.time.LocalDateTime.now()); + value.setStatus("CLEARANCE_COMPLETED"); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); } 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 fd8ac7df0..a49339834 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 @@ -38,6 +38,7 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -325,7 +326,7 @@ public class ReceivingContainerService { } } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Action request) { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); 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 75f63228e..c0709152b 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 @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -41,7 +42,7 @@ public class LoadingCompletedConsumer { @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public int consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) 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 f90963626..d66ef69ae 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 @@ -2,11 +2,16 @@ package com.baiyue.lastleg.inboundplan.pda; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; +import com.baiyue.common.event.EventEnvelope; import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.service.LoadingCompletedConsumer; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; import jakarta.validation.constraints.Size; import java.io.IOException; @@ -58,6 +63,17 @@ class ReceivingContainerContractTest { } } + @Test + void inventoryCreatingEntryTransactionsAvoidMissingDimensionGapLockDeadlocks() throws NoSuchMethodException { + Transactional pda = ReceivingContainerService.class + .getMethod("complete", String.class, ReceivingContainerRequests.Action.class) + .getAnnotation(Transactional.class); + Transactional bridge = LoadingCompletedConsumer.class.getMethod("consume", EventEnvelope.class) + .getAnnotation(Transactional.class); + assertTrue(pda != null && pda.isolation() == Isolation.READ_COMMITTED); + assertTrue(bridge != null && bridge.isolation() == Isolation.READ_COMMITTED); + } + @Test void schemaContainsCustomerScopedPlanChildrenAndGlobalPlatformPickerLocks() throws IOException { String schema = resource("db/lastleg.sql").toLowerCase(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 450762dac..29e680846 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -916,7 +916,7 @@ export class AcceptanceRun { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] + const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 489b9a335..7405b5832 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,7 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'\]/) + assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 18c01aa4b..2da55c862 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -138,6 +138,25 @@ async function uploadPhotos(context, token) { return photos } +async function awaitProducerCandidates(context, token, producers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return false + let response = null + let seen = new Set() + for (let attempt = 1; attempt <= 30; attempt += 1) { + response = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + const ready = expected.every((containerNo) => seen.has(containerNo)) + context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, + `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + return ready +} + function skuFixtures(context) { const boxes = context.createdResources?.orders?.boxTypes const orchestration = context.createdResources?.orchestrationFixtures || {} @@ -301,7 +320,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) - } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + } else if (preferredProducer?.destinationType !== 'OVERSEAS') { + blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') + } if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { const pallet = pallets.get(overseasInbound.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { @@ -309,7 +330,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) - } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } else if (preferredProducer?.destinationType !== 'FBA') { + blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + } const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) @@ -356,6 +379,12 @@ export async function run(context) { const matrix = context.createdResources?.phase4InboundProducerFixtures const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) + const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers + if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { + blockerGap(context, '海外待收柜生产矩阵', + '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + return + } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { context.pdaContainerSuffix = producer?.containerNo || `CONTAINER-${index + 1}` context.createdResources.phase4OverseasPda.finalStatus = null From 1e8d85c714f6cb8857c179886366e75a41423ab4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 22:51:33 +0800 Subject: [PATCH 126/231] fix: support id-only overseas destinations --- .../pda/service/InboundStockReceiptService.java | 7 +++++-- .../command/dto/InventoryCommandRequests.java | 4 ++-- .../command/service/InventoryCommandService.java | 14 ++++++++++---- .../command/InventoryCommandServiceTest.java | 16 ++++++++++++++++ .../phase3-port-schedules.mjs | 10 +++++----- .../phase3-producer-chain-contract.test.mjs | 1 + 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index f236833d4..114d20195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -188,8 +188,11 @@ public class InboundStockReceiptService { if (packages <= 0 || pallet.getWarehousePalletId() == null) { throw conflict("Stock-in requires picked packages and an assigned warehouse pallet"); } - String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()), - "Destination warehouse code is required for stock-in"); + String destinationCode = first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()); + if (destinationCode == null && trim(line.getDestinationWarehouseId()) == null) { + throw conflict("Destination warehouse ID or code is required for stock-in"); + } + if (destinationCode == null) destinationCode = ""; String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode(); String overseasWarehouseId = required(order.getOverseasWarehouseId(), "Actual overseas warehouse is required for stock-in"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java index 48e7b0a3d..2773296fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java @@ -17,7 +17,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, @@ -44,7 +44,7 @@ public final class InventoryCommandRequests { String overseasWarehouseCode, String overseasWarehouseName, String destinationWarehouseId, - @NotBlank String destinationWarehouseCode, + String destinationWarehouseCode, String destinationWarehouseName, @NotNull Long inboundOrderId, @NotBlank String inboundOrderNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index cc8d917af..f42015986 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -233,8 +233,14 @@ public class InventoryCommandService { String inboundCode, String requestedSkuCode, String productNameZh, String productNameEn) { String skuCode = sku(requestedSkuCode); + String destinationId = trim(destinationWarehouseId); + String destinationCode = trim(destinationWarehouseCode); + if (destinationId == null && destinationCode == null) { + throw badRequest("Destination warehouse ID or code is required"); + } + if (destinationCode == null) destinationCode = ""; InventoryBalance balance = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (balance != null) { mergeSkuSnapshot(balance, skuCode); return balance; @@ -246,8 +252,8 @@ public class InventoryCommandService { balance.setOverseasWarehouseId(overseasWarehouseId.trim()); balance.setOverseasWarehouseCode(trim(overseasWarehouseCode)); balance.setOverseasWarehouseName(trim(overseasWarehouseName)); - balance.setDestinationWarehouseId(trim(destinationWarehouseId)); - balance.setDestinationWarehouseCode(destinationWarehouseCode.trim()); + balance.setDestinationWarehouseId(destinationId); + balance.setDestinationWarehouseCode(destinationCode); balance.setDestinationWarehouseName(trim(destinationWarehouseName)); balance.setInboundOrderId(inboundOrderId); balance.setInboundOrderNo(inboundOrderNo.trim()); @@ -264,7 +270,7 @@ public class InventoryCommandService { return balance; } catch (DuplicateKeyException exception) { InventoryBalance concurrent = balances.lockDimension(customerId, overseasWarehouseId, - destinationWarehouseCode, inboundOrderId, inboundCode); + destinationCode, inboundOrderId, inboundCode); if (concurrent != null) { mergeSkuSnapshot(concurrent, skuCode); return concurrent; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 1fa98af51..5722fc1e3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -92,6 +92,22 @@ class InventoryCommandServiceTest { verify(ledgers).insert(any(InventoryLedger.class)); } + @Test + void registersIdOnlyOverseasDestinationWithoutInventingAWarehouseCode() { + when(balances.lockDimension("100", "WH-1", "", 101L, "IN-CODE-1")).thenReturn(null); + InventoryCommandRequests.InTransit request = new InventoryCommandRequests.InTransit( + "Customer 100", "WH-1", "WH1", "Warehouse 1", "DEST-OVERSEAS", null, + "New York warehouse", 101L, "IN-1", "IN-CODE-1", "SKU-1", "Product 1", null, + 1, "ORD-1", "EVT-1", "transit-id-only", null); + + service.registerInTransit("100", "SYSTEM", request); + + ArgumentCaptor inserted = ArgumentCaptor.forClass(InventoryBalance.class); + verify(balances).insert(inserted.capture()); + assertEquals("DEST-OVERSEAS", inserted.getValue().getDestinationWarehouseId()); + assertEquals("", inserted.getValue().getDestinationWarehouseCode()); + } + @Test void stockInTransfersPackagesFromInTransitToAvailable() { InventoryBalance balance = balance(200L, 0); diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 1611f64e5..314271727 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -56,9 +56,9 @@ function oceanDraft(context, suffix, master) { } } -async function ensureOceanBooking(context, suffix, master) { - if (context.createdResources?.oceanBooking?.id) { - const existingId = id(context.createdResources.oceanBooking.id) +async function ensureOceanBooking(context, suffix, master, preferredOceanBookingId = null) { + const existingId = id(preferredOceanBookingId || context.createdResources?.oceanBooking?.id) + if (existingId) { const response = await request(context, 'GET', `${OCEAN}/${existingId}`) const detail = context.apiSuccess(MODULE, '读取船期关联订舱真实柜次分配', response, `oceanBookingId=${existingId}`, OPTIONS) @@ -118,10 +118,10 @@ export async function run(context) { const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return - const ocean = await ensureOceanBooking(context, suffix, master) - if (!ocean?.id) return const flow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading + const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId) + if (!ocean?.id) return const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) ? id(flow.containerBookingId) : null const knownAllocation = containerBookingId diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index c5d841082..9d9038c6c 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -503,6 +503,7 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') + assert.match(schedules, /ensureOceanBooking\(context, suffix, master, flow\?\.oceanBookingId\)/) assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) From bf4fd95841374a3c2fe3321b4f56b9857bdc95f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:12:04 +0800 Subject: [PATCH 127/231] fix: align phase4 receiving and carrier fixtures --- .../pda/service/ReceivingContainerService.java | 9 --------- .../ReceivingContainerServiceBehaviorTest.java | 16 ++++++++++++++++ .../phase3-http-fail-closed-contract.test.mjs | 2 ++ .../admin-test-env-modules/phase3-loading.mjs | 12 +++++++++++- .../phase3-outbound-dispatch.mjs | 4 +++- .../phase3-producer-chain-contract.test.mjs | 5 +++++ 6 files changed, 37 insertions(+), 11 deletions(-) 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 a49339834..f76eaf789 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 @@ -449,15 +449,6 @@ public class ReceivingContainerService { if (pallet.fbaWarehouseCode() != null && !pallet.fbaWarehouseCode().isBlank()) { throw conflict("FBA-designated pallet cannot receive an OVERSEAS destination carton"); } - if (ref.line().getDestinationWarehouseId() != null && !ref.line().getDestinationWarehouseId().isBlank()) { - try { - if (Long.parseLong(ref.line().getDestinationWarehouseId()) != pallet.warehouseId()) { - throw conflict("Pallet destination warehouse does not match the inbound code"); - } - } catch (NumberFormatException ignored) { - // Destination codes that are not resource IDs are still protected by the actual overseas warehouse check. - } - } } } 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 0241f0153..61b54e06d 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 @@ -224,6 +224,22 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } + @Test + void overseasPalletUsesActualReceivingWarehouseInsteadOfBusinessDestinationId() { + stubCurrent("OVERSEAS", "CONT-A"); + InboundPlanLine overseasLine = line("OVERSEAS"); + overseasLine.setDestinationWarehouseId("900"); + when(lines.selectList(any())).thenReturn(List.of(overseasLine)); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, null)); + + var resolved = service.resolvePallet("7", + new ReceivingContainerRequests.PalletResolve("PAL-1", "IN-1")); + + assertThat(resolved.compatible()).isTrue(); + assertThat(resolved.warehouseId()).isEqualTo(100L); + assertThat(resolved.destinationType()).isEqualTo("OVERSEAS"); + } + @Test void containerCompletionRequiresEveryInboundCodeToBalanceExactly() { stubCurrent("FBA", "CONT-A"); diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 9f2107dbf..ae644258e 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -47,4 +47,6 @@ test('outbound lifecycle checks replay, hash conflict, stale version and final r '负向与幂等验证后回查出仓聚合', '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', ]) assert.match(source, new RegExp(step)) + assert.match(source, /if \(!confirmed\?\.aggregate\) return/) + assert.match(source, /hashConflict\.http === 200 && codeOf\(hashConflict\) === 'A0409'/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4280e8609..a5e5357b5 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -138,12 +138,22 @@ export async function run(context) { context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return + const carrierResponse = await context.request('GET', + '/api/platform/v1/carrier-schedule-queries/carriers') + const carriers = context.apiSuccess(MODULE, '读取船节点canonical船司目录', carrierResponse, '', OPTIONS) + const shippingCarrier = Array.isArray(carriers) + ? carriers.find((value) => value?.carrierCode === 'MATSON' && value?.carrierName) + : null + context.assert(MODULE, '订舱船司与订舱代理使用各自真实事实源', carrierResponse, + Boolean(shippingCarrier?.carrierName), + `carrierCode=${shippingCarrier?.carrierCode || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) + if (!shippingCarrier?.carrierName) return const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(supplier.id), shippingCompanyName: supplier.name, + shippingCompanyId: null, shippingCompanyName: shippingCarrier.carrierName, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 30d553185..94611f91e 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -107,6 +107,7 @@ export async function run(context) { && confirmed?.aggregate?.inboundStatus === 'LOADED' && Number(confirmed?.aggregate?.version) === expectedVersion + 1, `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) + if (!confirmed?.aggregate) return const replayResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, { 'Idempotency-Key': confirmKey }) @@ -118,7 +119,8 @@ export async function run(context) { const hashConflict = await request(context, 'POST', `${BASE}/confirm-dispatch`, { ...actionBody, remark: 'forged' }, { 'Idempotency-Key': confirmKey }) - context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, rejected(hashConflict), + context.addStep(MODULE, '相同 Idempotency-Key 不同请求拒绝', hashConflict, + hashConflict.http === 200 && codeOf(hashConflict) === 'A0409', `HTTP=${hashConflict.http}, code=${codeOf(hashConflict)}`, OPTIONS) const reverseKey = `${context.marker}-outbound-reverse`.slice(0, 128) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 9d9038c6c..84aa0b25e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -395,6 +395,11 @@ test('preload and loading versions use immediate operation baselines and exact p assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) + assert.match(loading, /carrier-schedule-queries\/carriers/) + assert.match(loading, /carrierCode === 'MATSON'/) + assert.match(loading, /shippingCompanyId: null, shippingCompanyName: shippingCarrier\.carrierName/) + assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) + assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { From 985d952e543899bde4c03f2c9c36017324208184 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:19:27 +0800 Subject: [PATCH 128/231] test: provision canonical carrier fixture --- .../admin-test-env-modules/phase3-loading.mjs | 50 +++++++++++++------ .../phase3-producer-chain-contract.test.mjs | 7 +-- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index a5e5357b5..b2b22f6ef 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -5,6 +5,7 @@ const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' function id(value) { return value == null ? '' : String(value) } export function markerSuffix(marker) { @@ -22,6 +23,27 @@ function failure(response) { || (response?.http === 200 && codeOf(response) !== '200') } +async function ensureMatsonSupplier(context, records) { + const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) + if (existing?.status === 'ACTIVE') return existing + if (existing?.id) { + const activatedResponse = await context.request('PATCH', + `/api/platform/v1/suppliers/${encodeURIComponent(id(existing.id))}/status?tenantId=0`, { status: 'ACTIVE' }) + const activated = context.apiSuccess(MODULE, '启用稳定MATSON船司开发夹具', activatedResponse, + `supplierId=${id(existing.id)}`, OPTIONS) + return activated?.id ? activated : null + } + const createdResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { + name: MATSON_SUPPLIER_NAME, category: 'BOOKING', region: '开发环境', legalRepresentative: '开发联调', + contactName: '开发联调', phone: '13800000000', email: 'matson-dev-fixture@example.com', + address: '开发环境稳定船司夹具', businessLicenseNo: 'DEV-CARRIER-MATSON', licenseFileId: null, + status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: '三四期开发环境HTTP验收稳定船司夹具', + }) + const supplierId = id(context.apiSuccess(MODULE, '创建稳定MATSON船司开发夹具', createdResponse, + `name=${MATSON_SUPPLIER_NAME}`, OPTIONS)) + return supplierId ? { id: supplierId, name: MATSON_SUPPLIER_NAME, status: 'ACTIVE' } : null +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -132,28 +154,28 @@ export async function run(context) { const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading' const supplierResponse = await context.request('GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') - const supplierPage = context.apiSuccess(MODULE, '读取 ACTIVE 订舱代理作为运输事实源', supplierResponse, '', OPTIONS) - const supplier = supplierPage?.records?.find?.((value) => value?.id && value?.name) + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200') + const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) + const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] + const supplier = supplierRecords.find((value) => value?.id && value?.name && value?.status === 'ACTIVE' + && value.name !== MATSON_SUPPLIER_NAME) context.assert(MODULE, '存在可用于发布订舱的 ACTIVE 订舱代理', supplierResponse, Boolean(supplier?.id && supplier?.name), `supplierId=${id(supplier?.id) || 'missing'}`, OPTIONS) if (!supplier?.id) return - const carrierResponse = await context.request('GET', - '/api/platform/v1/carrier-schedule-queries/carriers') - const carriers = context.apiSuccess(MODULE, '读取船节点canonical船司目录', carrierResponse, '', OPTIONS) - const shippingCarrier = Array.isArray(carriers) - ? carriers.find((value) => value?.carrierCode === 'MATSON' && value?.carrierName) - : null - context.assert(MODULE, '订舱船司与订舱代理使用各自真实事实源', carrierResponse, - Boolean(shippingCarrier?.carrierName), - `carrierCode=${shippingCarrier?.carrierCode || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) - if (!shippingCarrier?.carrierName) return + const shippingCarrier = await ensureMatsonSupplier(context, supplierRecords) + context.assert(MODULE, '订舱船司与订舱代理使用各自真实供应商事实', supplierResponse, + Boolean(shippingCarrier?.id && shippingCarrier?.name === MATSON_SUPPLIER_NAME), + `shippingCompanyId=${id(shippingCarrier?.id) || 'missing'}, bookingAgentId=${id(supplier.id)}`, OPTIONS) + if (!shippingCarrier?.id) return + context.createdResources.phase3CarrierSupplier = { + supplierId: id(shippingCarrier.id), name: shippingCarrier.name, status: 'ACTIVE', stableDevFixture: true, + } const bookingDraft = { customerId: tenantId, customerName: initial?.summary?.customerName, warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: null, shippingCompanyName: shippingCarrier.carrierName, + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 84aa0b25e..735a08096 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -395,9 +395,10 @@ test('preload and loading versions use immediate operation baselines and exact p assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) - assert.match(loading, /carrier-schedule-queries\/carriers/) - assert.match(loading, /carrierCode === 'MATSON'/) - assert.match(loading, /shippingCompanyId: null, shippingCompanyName: shippingCarrier\.carrierName/) + assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) + assert.match(loading, /category=BOOKING&page=1&size=200/) + assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/) + assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) }) From 615ecb4ba1d54604e28bed595fec9a2487c1ebc3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:28:24 +0800 Subject: [PATCH 129/231] test: complete overseas carton and sailing fixtures --- .../phase3-loading-finalize.mjs | 1 + .../admin-test-env-modules/phase3-loading.mjs | 2 + .../phase3-port-schedules.mjs | 3 +- .../phase3-producer-chain-contract.test.mjs | 4 ++ .../phase4-overseas-pda-contract.test.mjs | 2 + .../phase4-overseas-pda.mjs | 61 ++++++++++++------- 6 files changed, 48 insertions(+), 25 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index e64e70553..aa7bc88c8 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -366,6 +366,7 @@ export async function run(context) { sourceOrderId: orderId, sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, + cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index b2b22f6ef..38c2da961 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1213,6 +1213,8 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, cartonMark: source?.boxCode || source?.cartonMark || null, + cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) + .map((value) => String(value || '').trim()).filter(Boolean))], destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 314271727..823a79b81 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -122,8 +122,7 @@ export async function run(context) { || context.createdResources?.phase3ContainerLoading const ocean = await ensureOceanBooking(context, suffix, master, flow?.oceanBookingId) if (!ocean?.id) return - const containerBookingId = ['COMPLETED', 'LOADED'].includes(flow?.finalStatus) - ? id(flow.containerBookingId) : null + const containerBookingId = id(flow?.containerBookingId) || null const knownAllocation = containerBookingId && ocean.allocatedContainerBookingIds?.includes(containerBookingId) ? containerBookingId : null if (containerBookingId && !knownAllocation) { diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 735a08096..4d1662b28 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -401,6 +401,8 @@ test('preload and loading versions use immediate operation baselines and exact p assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.doesNotMatch(loading, /shippingCompanyId: id\(supplier\.id\), shippingCompanyName: supplier\.name/) + assert.match(loading, /cartonMarks: \[\.\.\.new Set\(\(stagedTask\?\.items \|\| \[\]\)\.flatMap/) + assert.match(source('./phase3-loading-finalize.mjs'), /cartonMarks: Array\.isArray\(fixture\.cartonMarks\)/) }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { @@ -510,6 +512,8 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/) const schedules = source('./phase3-port-schedules.mjs') assert.match(schedules, /ensureOceanBooking\(context, suffix, master, flow\?\.oceanBookingId\)/) + assert.match(schedules, /const containerBookingId = id\(flow\?\.containerBookingId\) \|\| null/) + assert.doesNotMatch(schedules, /\['COMPLETED', 'LOADED'\]\.includes\(flow\?\.finalStatus\)/) assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index ab045f947..07be663c7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -43,6 +43,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/) assert.match(source, /目的仓 .* 不得代替物理仓/) assert.match(source, /retained as acceptance evidence/) + assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) + assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 2da55c862..1720315b5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -202,27 +202,40 @@ async function receiveFba(context, token, inbound, pallet) { return true } -async function receiveOverseas(context, token, inbound, pallet, producerCartonMark = null) { - const cartonMark = producerCartonMark || context.createdResources?.inboundMeasurement?.boxCode - if (!nonBlank(cartonMark)) { - blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') +async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { + const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + .map(id).filter(nonBlank))] + if (!cartonMarks.length) { + blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') return false } - const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) - if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) { - blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response) - return false + for (const [index, cartonMark] of cartonMarks.entries()) { + const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode) + const pallet = inbound ? pallets.get(inbound.inboundCode) : null + if (!success(resolved.response) || !inbound || !pallet) { + blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, + `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`, + 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, + idempotencyKey: key(context, 'overseas-carton', cartonMark) } + const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键同载荷重放`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, `OVERSEAS逐箱${index + 1}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `OVERSEAS逐箱${index + 1}同键异载荷冲突`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, + '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, 'overseas-duplicate', cartonMark) }, conflict) } - const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') } - const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) - if (!success(first.response)) return false - const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) - context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response, - success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) - await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) - await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict) return true } @@ -312,7 +325,7 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) } const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') - const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS') + const overseasInbounds = current.data.inbounds.filter((item) => item.destinationType === 'OVERSEAS') if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { const firstFba = fbaInbounds[0] await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { @@ -323,13 +336,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe } else if (preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } - if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { - const pallet = pallets.get(overseasInbound.inboundCode) + if (overseasInbounds.length && overseasInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstOverseas = overseasInbounds[0] + const pallet = pallets.get(firstOverseas.inboundCode) await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { - inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + inboundCode: firstOverseas.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) - await receiveOverseas(context, token, overseasInbound, pallet, preferredProducer?.cartonMark) + await receiveOverseas(context, token, overseasInbounds, pallets, + preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark]) } else if (preferredProducer?.destinationType !== 'FBA') { blockerGap(context, 'OVERSEAS逐箱链', '当前OVERSEAS柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') } From 058bf9f4e6979065f2ee17ac9171bad68e348419 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:35:26 +0800 Subject: [PATCH 130/231] test: complete phase4 cross-region handoff --- .../phase4-carrier-nodes-contract.test.mjs | 4 ++-- .../phase4-carrier-nodes.mjs | 2 +- .../phase4-overseas-pda-contract.test.mjs | 3 +++ .../phase4-overseas-pda.mjs | 23 ++++++++++++++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 1fd31f443..edce73035 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -128,7 +128,7 @@ function markerOwnedContext() { }] const histories = [{ id: String(nextId++), action: 'AUTO_CREATE', fromVersion: null, toVersion: 0, - reason: '国内确认出仓自动创建', operatorId: CUSTOMER_ID, operatorName: 'fixture', + reason: '国内确认出仓自动创建', operatorId: '1000001', operatorName: 'fixture', createdAt: '2026-08-24T08:00:01', }] const detail = () => ({ query: { query: clone(task), expired: true }, @@ -178,7 +178,7 @@ function markerOwnedContext() { } else task.status = 'READY' } histories.push({ id: String(nextId++), action: 'MANUAL_NODE', fromVersion: task.version - 1, - toVersion: task.version, reason: body.reason, operatorId: CUSTOMER_ID, operatorName: 'fixture', + toVersion: task.version, reason: body.reason, operatorId: '1000001', operatorName: 'fixture', createdAt: body.observedAt }) const saved = detail(); idempotency.set(key, { signature, detail: clone(saved) }) return ok(saved, `req-${key}`) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index a2a5ba478..c5883a15b 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -71,7 +71,7 @@ function validHistory(item) { return validLongId(item?.id) && Boolean(item?.action) && (item?.fromVersion == null || number(item.fromVersion)) && (item?.toVersion == null || number(item.toVersion)) - && nullableText(item?.reason) && nullableId(item?.operatorId) + && nullableText(item?.reason) && (item?.operatorId == null || validNumericId(item.operatorId)) && nullableText(item?.operatorName) && nullableIso(item?.createdAt) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 07be663c7..45e5a374e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -45,6 +45,9 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) + assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) + assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) + assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 1720315b5..d24debae7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -17,6 +17,7 @@ function forbidden(response) { return [401, 403].includes(response?.http) || (re function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${context.pdaContainerSuffix || ''}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } function markerSuffix(context) { return `${context.marker}${context.pdaContainerSuffix || ''}`.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } +function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) @@ -35,6 +36,23 @@ async function expected(context, token, method, name, path, body, predicate) { return response } +async function captureCompletedOrder(context, containerNo, expectedCustomerId) { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const candidates = records(response?.body?.data) + .filter((item) => id(item?.containerNo) === id(containerNo)) + const exactCustomer = expectedCustomerId == null ? candidates + : candidates.filter((item) => id(item?.customerId) === id(expectedCustomerId)) + const order = exactCustomer[0] || candidates[0] + const mapped = success(response) && longId(order?.id) && nonBlank(id(order?.customerId)) + context.assert(MODULE, '完成柜回查平台尾程订单并冻结跨区身份', response, mapped, + `containerNo=${containerNo}, orderId=${id(order?.id) || 'missing'}, customerId=${id(order?.customerId) || 'missing'}; ${trace(response)}`, OPTIONS) + if (mapped) { + context.createdResources.phase4OverseasPda.orderId = id(order.id) + context.createdResources.phase4OverseasPda.customerId = id(order.customerId) + } +} + async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture if (!fixture?.username || !fixture?.password) return null @@ -362,11 +380,14 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null + if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { + await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + } } export async function run(context) { context.createdResources.phase4OverseasPda = { - marker: context.marker, containerNo: null, customerId: null, + marker: context.marker, containerNo: null, customerId: null, orderId: null, palletIds: [], locationIds: [], finalStatus: null, pickerMembershipIds: [], completedContainers: [], overseasWarehouseId: null, overseasWarehouseName: null, cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', From a22a284e7dd4f5f31d1bc75400c96748c2edcdaa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:43:08 +0800 Subject: [PATCH 131/231] fix: route unload events to domestic bridge --- config/nacos/overseas/lastleg-service.yml | 2 +- deploy/docker-compose.overseas.yml | 7 ++++++- deploy/docker-compose.yml | 4 ++++ .../phase4-carrier-nodes-contract.test.mjs | 5 +++++ scripts/admin-test-env-modules/phase4-carrier-nodes.mjs | 5 ++++- scripts/overseas-deployment-contract.test.mjs | 6 +++++- 6 files changed, 25 insertions(+), 4 deletions(-) diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index b4e34c36d..e6241c4b6 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -21,7 +21,7 @@ baiyue: warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL} + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index e807b86e2..0c95a4301 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -58,7 +58,8 @@ services: LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} @@ -89,6 +90,10 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + networks: + firstleg-overseas-network: + infra-network: + aliases: [event-bridge-overseas] networks: firstleg-overseas-network: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 385efc81b..e917070cf 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -245,6 +245,10 @@ services: EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} SPRING_SQL_INIT_MODE: never + networks: + firstleg-network: + infra-network: + aliases: [event-bridge-cn] audit-service: <<: *common diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index edce73035..2b88fe588 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -53,6 +53,11 @@ test('canonical states, ten ordered nodes, exception codes and complete payload 'locationCode', 'locationName', 'reason']) }) +test('actual node timestamps advance from the frozen outbound-confirm fact instead of the runner timezone', () => { + assert.match(source, /Date\.parse\(`\$\{frozenLatestActual\}Z`\)/) + assert.match(source, /frozenLatestActualMs\) \? Date\.now\(\) : frozenLatestActualMs/) +}) + test('module exposes no scheduler, attempt, internal or arbitrary stop endpoint', () => { assert.match(source, /\/manual-nodes/) assert.match(source, /'Idempotency-Key'/) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index c5883a15b..bd0bea3a9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -184,7 +184,10 @@ export async function run(context) { `version=${detail?.query?.query?.version ?? 'missing'}, nodes=${detail?.nodes?.length ?? 'missing'}`, OPTIONS) let version = Number(detail.query.query.version) - const baseActual = Date.now() + 60 * 60_000 + const frozenLatestActual = detail?.query?.query?.latestActualAt + const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN + : Date.parse(`${frozenLatestActual}Z`) + const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 for (let index = 0; index < STANDARD_NODES.length; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 3577c68a7..ce3a29362 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -53,6 +53,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /aliases: \[event-bridge-overseas\]/) }) test('CN and overseas event bridges have explicit opposite-region consumers', async () => { @@ -66,10 +69,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) + assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /FIRSTLEG_EVENT_BRIDGE_URL/) + assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) From 55a9d1af09d10c95cc3f45c77ee270fe00aba8af Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:49:07 +0800 Subject: [PATCH 132/231] fix: prioritize domestic bridge endpoint --- .../baiyue/lastleg/client/EventBridgeClient.java | 2 +- .../src/main/resources/application.yml | 2 +- .../client/EventBridgeClientContractTest.java | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java index e6ab0d6fa..5a09c0bc4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** 将海外业务 Outbox 投递到国内区域事件桥。 */ @FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", - url = "${baiyue.firstleg.event-bridge-url:}") + url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index d9e11b65c..1aa3d7f34 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -50,7 +50,7 @@ baiyue: event-bridge: url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} firstleg: - event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL:} + event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java new file mode 100644 index 000000000..5891459d7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeClientContractTest { + @Test + void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { + FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(contract.url()) + .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); + } +} From 1c40548986b38be57ebd249922a509728e301617 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:52:38 +0800 Subject: [PATCH 133/231] fix: isolate domestic bridge target variable --- deploy/docker-compose.overseas.yml | 4 ++-- scripts/overseas-deployment-contract.test.mjs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 0c95a4301..b79552050 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -58,8 +58,8 @@ services: LASTLEG_DB_URL: ${LASTLEG_DB_URL} LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} - FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_URL:-http://event-bridge-cn:9312} + CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} + FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} FILE_SERVICE_URL: ${FILE_SERVICE_URL} WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index ce3a29362..2b86a3fce 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -53,8 +53,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) + assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) assert.match(compose, /aliases: \[event-bridge-overseas\]/) }) From e94f8629d277bf37a0a7f03d7e0c4449d5d93d12 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 23:56:52 +0800 Subject: [PATCH 134/231] fix: retain event bridge rejection evidence --- .../InboundUnloadCompletedOutboxRelay.java | 19 +++++++++++++++---- ...InboundUnloadCompletedOutboxRelayTest.java | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index dcdaa2c9f..e9a5e50c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -39,10 +39,7 @@ public class InboundUnloadCompletedOutboxRelay { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response = client.receive(internalToken, event); - if (response == null || !response.successful() || response.data() == null - || !event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件"); - } + requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), @@ -51,4 +48,18 @@ public class InboundUnloadCompletedOutboxRelay { } } } + + private void requireAcknowledgement(EventEnvelope event, + com.baiyue.common.core.api.ApiResponse response) { + if (response == null) throw new IllegalStateException("国内事件桥返回空响应"); + if (!response.successful()) { + throw new IllegalStateException("国内事件桥拒绝海外卸柜完成事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId()); + } + if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据"); + if (!event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId() + + ", actual=" + response.data().eventId()); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 0e4946ebb..dc52dd01d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -25,6 +25,7 @@ import java.util.List; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.baiyue.common.core.api.ResultCode; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxRelayTest { @@ -78,6 +79,24 @@ class InboundUnloadCompletedOutboxRelayTest { verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, + "事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + contains("事件目标区域与当前事件桥区域不一致")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); From 24149c062a34c0048c61c27f11273e0f774ca485 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:02:45 +0800 Subject: [PATCH 135/231] fix: migrate event bridge customer columns --- deploy/sql/头程数据库统一增量-MySQL8.sql | 43 +++++++++++++++++-- scripts/overseas-deployment-contract.test.mjs | 12 ++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 08bbccb59..a6bd2d6c0 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -160,7 +160,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( event_version INT NOT NULL COMMENT '事件契约版本', source_region VARCHAR(32) NOT NULL COMMENT '来源区域', target_region VARCHAR(32) NOT NULL COMMENT '目标区域', - tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', + customer_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', aggregate_version BIGINT NOT NULL COMMENT '聚合版本', @@ -184,6 +184,41 @@ CREATE TABLE IF NOT EXISTS bridge_inbox ( KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until) ) ENGINE=InnoDB COMMENT='跨区域事件已接收箱'; +-- 旧开发库曾使用 tenant_id;当前 Event Bridge 实体统一为 customer_id。幂等改名并保留原数据。 +SET @bridge_inbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'customer_id' +); +SET @bridge_inbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_inbox' AND column_name = 'tenant_id' +); +SET @bridge_inbox_column_sql = IF( + @bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1, + 'ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_inbox_column_statement FROM @bridge_inbox_column_sql; +EXECUTE bridge_inbox_column_statement; +DEALLOCATE PREPARE bridge_inbox_column_statement; + +SET @bridge_outbox_customer_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'customer_id' +); +SET @bridge_outbox_tenant_column = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = 'baiyue_event_bridge' AND table_name = 'bridge_outbox' AND column_name = 'tenant_id' +); +SET @bridge_outbox_column_sql = IF( + @bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1, + 'ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id', + 'SELECT 1' +); +PREPARE bridge_outbox_column_statement FROM @bridge_outbox_column_sql; +EXECUTE bridge_outbox_column_statement; +DEALLOCATE PREPARE bridge_outbox_column_statement; + CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', @@ -12397,7 +12432,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'status', '消费状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_inbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_id', '聚合标识'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'aggregate_type', '聚合类型'); @@ -12422,7 +12457,7 @@ CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'retry_count', CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'source_region', '来源区域'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'status', '投递状态'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'target_region', '目标区域'); -CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'tenant_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); +CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'customer_id', '客户或业务隔离标识;跨区域事件沿用来源系统口径'); CALL queue_column_comment('baiyue_event_bridge', 'bridge_outbox', 'trace_id', '链路标识'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'client_ip', '客户端IP'); CALL queue_column_comment('baiyue_file', 'file_download_log', 'downloaded_at', '下载时间'); diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 2b86a3fce..82c4591fd 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -101,3 +101,15 @@ test('domestic Auth receives the overseas JWT signing configuration', async () = 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) }) + +test('domestic event bridge SQL converges legacy tenant columns to the customer entity contract', async () => { + const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') + const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') + assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.equal((incremental.slice(0, incremental.indexOf('CREATE TABLE IF NOT EXISTS bridge_delivery_attempt')) + .match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) + assert.match(incremental, /ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id/) + assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) + assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) +}) From 84ad5e779af8e2bf93412b663431c30ea09d94a9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:08:59 +0800 Subject: [PATCH 136/231] test: make price-book acceptance portable --- .../pricing-contract.test.mjs | 16 ++++++++ scripts/admin-test-env-modules/pricing.mjs | 38 ++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 scripts/admin-test-env-modules/pricing-contract.test.mjs diff --git a/scripts/admin-test-env-modules/pricing-contract.test.mjs b/scripts/admin-test-env-modules/pricing-contract.test.mjs new file mode 100644 index 000000000..55cc64b54 --- /dev/null +++ b/scripts/admin-test-env-modules/pricing-contract.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'pricing.mjs'), 'utf8') + +test('price-book acceptance is portable and reuses a deployed active source by default', () => { + assert.match(source, /process\.env\.ADMIN_TEST_PRICE_BOOK_FILE/) + assert.match(source, /activeBefore\[0\]/) + assert.match(source, /requestBinary\('GET', `\$\{PRICE_BOOK_PATH\}\/\$\{encodeURIComponent\(activeFixture\.id\)\}\/source`\)/) + assert.match(source, /复用当前生效价格源作为本轮隔离上传夹具/) + assert.doesNotMatch(source, /WXWork|Users\\BY-OY|DEFAULT_PRICE_BOOK_FILE/) +}) diff --git a/scripts/admin-test-env-modules/pricing.mjs b/scripts/admin-test-env-modules/pricing.mjs index 1853dbf82..91952b572 100644 --- a/scripts/admin-test-env-modules/pricing.mjs +++ b/scripts/admin-test-env-modules/pricing.mjs @@ -5,7 +5,6 @@ import { basename } from 'node:path' const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books' const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC' const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS' -const DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx' const EXPECTED_CHANNEL_CODES = [ 'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY', 'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK', @@ -134,18 +133,37 @@ export async function run(context) { Array.isArray(versionsBefore) && activeBefore.length <= 1, `versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker) - const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE || DEFAULT_PRICE_BOOK_FILE + const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE let sourceBytes - try { - sourceBytes = await readFile(sourcePath) - } catch (error) { - context.addStep(module, '读取船期价格测试文件', null, false, - `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) - finishScenario(context, module, scenario, start) - return + let sourceName + if (sourcePath) { + try { + sourceBytes = await readFile(sourcePath) + sourceName = basename(sourcePath) + } catch (error) { + context.addStep(module, '读取显式船期价格测试文件', null, false, + `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) + finishScenario(context, module, scenario, start) + return + } + } else { + const activeFixture = activeBefore[0] + const sourceResponse = activeFixture?.id + ? await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(activeFixture.id)}/source`) + : null + const zipMagic = sourceResponse?.bytes?.[0] === 0x50 && sourceResponse?.bytes?.[1] === 0x4b + const fixtureReady = sourceResponse?.http === 200 && sourceResponse.bytes?.length > 0 && zipMagic + context.addStep(module, '复用当前生效价格源作为本轮隔离上传夹具', sourceResponse, fixtureReady, + `activeVersionId=${activeFixture?.id || 'missing'}, bytes=${sourceResponse?.bytes?.length || 0}, zipMagic=${zipMagic}`, blocker) + if (!fixtureReady) { + finishScenario(context, module, scenario, start) + return + } + sourceBytes = sourceResponse.bytes + sourceName = activeFixture.originalFileName || `price-book-${activeFixture.id}.xlsx` } const sourceHash = sha256(sourceBytes) - const uploadName = `${context.marker}-${basename(sourcePath)}` + const uploadName = `${context.marker}-${sourceName}` context.addStep(module, '船期价格测试文件已隔离', null, sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'), `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) From 73d206e3f35d2b8cdf17236ef958b793dd8d9fbc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 00:24:43 +0800 Subject: [PATCH 137/231] test: complete phase3 phase4 acceptance coverage --- .../mapper/OutboundDispatchReadMapper.java | 20 ++ .../OutboundDispatchRepository.java | 2 +- ...utboundDispatchReadMapperContractTest.java | 22 ++ scripts/admin-test-env-acceptance.mjs | 11 +- .../manual-state-matrix-contract.test.mjs | 4 +- .../manual-state-matrix.mjs | 14 +- .../master-data-version-contract.test.mjs | 12 + .../admin-test-env-modules/master-data.mjs | 6 +- ...-inbound-status-fixtures-contract.test.mjs | 16 +- .../order-inbound-status-fixtures.mjs | 28 -- .../phase3-http-fail-closed-contract.test.mjs | 3 +- .../phase3-outbound-aggregate.mjs | 8 +- ...overseas-client-services-contract.test.mjs | 56 ++++ .../phase4-overseas-client-services.mjs | 253 ++++++++++++++++++ ...phase4-overseas-dispatch-contract.test.mjs | 51 ++++ .../phase4-overseas-dispatch.mjs | 169 ++++++++++++ ...rseas-inbound-operations-contract.test.mjs | 32 +++ .../phase4-overseas-inbound-operations.mjs | 212 +++++++++++++++ .../supplemental-boundaries-contract.test.mjs | 14 + .../supplemental-boundaries.mjs | 29 +- ...verseas-acceptance-entry-contract.test.mjs | 15 ++ 21 files changed, 908 insertions(+), 69 deletions(-) create mode 100644 scripts/admin-test-env-modules/master-data-version-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-client-services.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs create mode 100644 scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 7bee3dbce..9778a6012 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -87,6 +87,25 @@ public interface OutboundDispatchReadMapper { SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId} UNION ALL + SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at + FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id + WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId} + AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId} + UNION ALL + SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at + FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id + WHERE port_schedule.customer_id=#{tenantId} AND port_schedule.ocean_booking_id=#{oceanBookingId} + AND port_schedule.container_booking_id=#{containerBookingId} + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item schedule_item + JOIN fl_loading_plan schedule_plan ON schedule_plan.id=schedule_item.loading_plan_id + JOIN fl_inbound_order schedule_inbound ON schedule_inbound.id=schedule_item.inbound_id + AND schedule_inbound.tenant_id=schedule_item.account_id AND schedule_inbound.deleted=0 + WHERE schedule_item.deleted=0 AND schedule_plan.container_booking_id=port_schedule.container_booking_id + AND schedule_inbound.tenant_id=#{tenantId} AND schedule_inbound.order_id=#{orderId} + AND schedule_inbound.id=#{inboundId} + ) + UNION ALL SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId} AND h.container_booking_id=#{containerBookingId} @@ -95,6 +114,7 @@ public interface OutboundDispatchReadMapper { List timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, + @Param("orderId") long orderId, @Param("oceanBookingId") Long oceanBookingId, @Param("truckAppointmentId") Long truckAppointmentId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index dc882f01c..a0f87004a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -57,6 +57,6 @@ public class OutboundDispatchRepository { } public List timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) { return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(), - binding.loadingPlanId(), oceanId, truckId); + binding.loadingPlanId(), binding.orderId(), oceanId, truckId); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 81158d28a..d4f2eebfd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -45,4 +45,26 @@ class OutboundDispatchReadMapperContractTest { assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); } + + @Test + void timelineIncludesCustomsAndSailingHistoriesWithinTheFullOutboundScope() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("timeline", long.class, long.class, long.class, long.class, + long.class, Long.class, Long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case") + .contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}") + .contains("customs_case.inbound_id=#{inboundId}", + "customs_case.container_booking_id=#{containerBookingId}") + .contains("SELECT 'SAILING_SCHEDULE'", + "FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule") + .contains("port_schedule.customer_id=#{tenantId}", + "port_schedule.ocean_booking_id=#{oceanBookingId}") + .contains("port_schedule.container_booking_id=#{containerBookingId}") + .contains("schedule_inbound.tenant_id=#{tenantId}", "schedule_inbound.order_id=#{orderId}") + .contains("schedule_inbound.id=#{inboundId}") + .contains("ORDER BY occurredAt,module"); + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 29e680846..d230f0a72 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -30,6 +30,9 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], + ['phase4-overseas-client-services', ['phase4-overseas-pda']], + ['phase4-overseas-dispatch', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], ]) @@ -113,6 +116,12 @@ export function normalizeModuleOrder(modules) { && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } + for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch']) { + if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda')) { + normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda') + } + } if (normalized.includes('phase4-overseas-pda')) { const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize'] diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs index 329cafb3b..29201174a 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -86,6 +86,7 @@ test('fixture-unavailable statuses stay supported without becoming hard requirem assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable') assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable') assert.deepEqual(missingRequiredStateFixtures(matrix), []) + assert.match(source, /gap\.category, \{ marker: context\.marker, page, status, blocking: false \}/) }) test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => { @@ -111,6 +112,5 @@ test('LOADING and LOADED fixtures are resolved through inbound and unified-order assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/) assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/) assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/) - assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/) - assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/) + assert.doesNotMatch(source, /clearResolvedProvisionalGap/) }) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs index 63045f85a..8389341fd 100644 --- a/scripts/admin-test-env-modules/manual-state-matrix.mjs +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -53,22 +53,11 @@ function registerFixtureUnavailableGaps(context, matrix) { if (rows(context.coverageGaps).some((item) => item?.category === gap.category && String(item?.name || '').includes(status))) continue context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason, - gap.category, { marker: context.marker, page, status }) + gap.category, { marker: context.marker, page, status, blocking: false }) } } } -function clearResolvedProvisionalGap(context, status) { - if (!Array.isArray(context.coverageGaps)) return - for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) { - const gap = context.coverageGaps[index] - if (gap?.module === 'order-inbound-status-fixtures' - && gap?.scenario === 'manual-order-inbound-status-matrix' - && gap?.category === 'fixture-unavailable' - && gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1) - } -} - async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) { const resource = context.createdResources?.[resourceName] const inboundId = id(resource?.inboundId) @@ -100,7 +89,6 @@ async function attachUnifiedOrderFixture(context, matrix, resourceName, expected id: orderId, status: expectedStatus, inboundId, detailPath: `/orders/${orderId}`, } - clearResolvedProvisionalGap(context, expectedStatus) } export async function run(context) { diff --git a/scripts/admin-test-env-modules/master-data-version-contract.test.mjs b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs new file mode 100644 index 000000000..6b2f0d5f6 --- /dev/null +++ b/scripts/admin-test-env-modules/master-data-version-contract.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./master-data.mjs', import.meta.url), 'utf8') + +test('non-versioned master data stays explicit without making the full gate permanently red', () => { + assert.match(source, /documented-non-versioned-contract/) + assert.match(source, /\{ blocking: false \}/) + assert.match(source, /非版本化主数据不伪造旧version请求/) + assert.match(source, /phase4-overseas-master-data/) +}) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index c8c8160fe..a62c7106d 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -241,7 +241,11 @@ export async function run(context) { } context.addCoverageGap(module, 'optimistic-lock', '仓库/托盘/快递/往来单位旧 version 冲突', - '真实 DTO 未声明 version/expectedVersion 字段,不能伪造并发冲突断言;库位版本已由 supplemental-boundaries 覆盖', 'contract-gap') + '当前稳定 DTO 明确不声明 version/expectedVersion;库位版本冲突由 supplemental-boundaries 覆盖,四期报价/汇率/车辆由 phase4-overseas-master-data 覆盖', + 'documented-non-versioned-contract', { blocking: false }) + context.addStep(module, '非版本化主数据不伪造旧version请求', null, true, + '仓库/托盘/快递/往来单位沿用当前非版本化稳定契约;版本化资源在各自专项验收', + { scenario: 'optimistic-lock', deploymentBlocker: false }) const disableWarehouseResponse = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${warehouseId}/status?tenantId=0&status=INACTIVE`) context.apiSuccess(module, '停用测试仓库', disableWarehouseResponse) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 5602f6540..2ad54d750 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -40,8 +40,7 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /入库列表保留 MEASURE_REJECTED/) - assert.match(source, /稳定 Admin 契约未开放 measurements\/reject/) + assert.doesNotMatch(source, /recordCoverageGap|addCoverageGap/) }) test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { @@ -63,26 +62,23 @@ test('测量状态截停发生在正确的 HTTP 边界', () => { assert.doesNotMatch(source, /target === 'MEASURE_REJECTED' && isSubmit/) }) -test('入库不可独立保留状态显式阻断', () => { +test('入库模块只声明状态全集且不在后续专项生成前预写失败步骤', () => { for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) { - assert.match(source, new RegExp(`${status}:`)) + assert.match(source, new RegExp(`'${status}'`)) } - assert.match(source, /AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 WAIT_SPLIT/) assert.match(source, /processStatus=WAIT_SPLIT/) assert.match(source, /查询存量 WAIT_SPLIT 样本/) - assert.match(source, /没有作废入库单 API/) - assert.match(source, /recordCoverageGap\(context, `入库列表保留 \$\{status\}`/) - assert.match(source, /context\.addCoverageGap\?\./) + assert.doesNotMatch(source, /覆盖阻断|fixture-unavailable|recordCoverageGap|addCoverageGap/) }) -test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => { +test('统一订单声明完整状态集合并由总矩阵统一冻结不可达状态', () => { assert.match(source, /result\.unifiedOrders\.PENDING_REVIEW/) assert.match(source, /result\.unifiedOrders\.WAIT_MEASURE/) for (const status of ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) { assert.match(source, new RegExp(`'${status}'`)) } assert.doesNotMatch(source, /WAIT_SPLIT 标记为历史废弃状态/) - assert.match(source, /没有完成派送并写 DELIVERED 的公开业务 API/) + assert.doesNotMatch(source, /没有完成派送并写 DELIVERED/) }) test('按总门禁格式写入 matrix.pages 的 requiredStatuses 和 fixtures', () => { diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 86e269a01..fdfe9b1b9 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -31,13 +31,6 @@ function key(context, operation) { return `${context.marker}-manual-${operation} function query(value) { return encodeURIComponent(String(value)) } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } -function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') { - context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker }) - context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, { - scenario: OPTIONS.scenario, deploymentBlocker: false, - }) -} - async function merchantRequest(context, method, path, body, token, headers = {}) { const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers } const payload = body === undefined ? undefined : JSON.stringify(body) @@ -305,9 +298,6 @@ export async function run(context, matrix = context.createdResources.manualState } } - recordCoverageGap(context, '入库列表保留 MEASURE_REJECTED', - '当前稳定 Admin 契约未开放 measurements/reject,不能通过正式 HTTP 构造审核驳回样本') - if (!result.inbounds.WAIT_SPLIT) { const legacyResponse = await context.request('GET', `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) @@ -327,24 +317,6 @@ export async function run(context, matrix = context.createdResources.manualState } } - const blockers = { - WAIT_PRICING: '审核通过先进入 WAIT_PRICING,但自动计费与入库编排会继续推进,不能稳定停留', - FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前', - INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 WAIT_SPLIT,无可暂停 API', - VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API', - } - for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) { - recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status]) - } - const unifiedBlockers = { - LOADING: '需要分柜、排柜及 PDA 专项独立样本', - LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本', - DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API', - } - for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) { - recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status]) - } - context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, `orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index ae644258e..fd73c84af 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -33,7 +33,8 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/) - assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/) + assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/) + assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index aee6aa3d9..6ab6c718f 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -29,7 +29,7 @@ export async function run(context) { const aggregate = detail?.aggregate const timeline = Array.isArray(detail?.timeline) ? detail.timeline : [] const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE', - 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'OUTBOUND_DISPATCH'] + 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'CUSTOMS', 'SAILING_SCHEDULE', 'OUTBOUND_DISPATCH'] context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response, id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId) && id(aggregate?.inboundId) === id(fixture.inboundId) @@ -48,10 +48,4 @@ export async function run(context) { context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) } - - context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线', - '当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影', - 'contract-gap') - context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false, - '当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs new file mode 100644 index 000000000..821664d07 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services-contract.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const source = await readFile(fileURLToPath(new URL('./phase4-overseas-client-services.mjs', import.meta.url)), 'utf8') + +test('client services module uses only stable merchant self-service paths', () => { + for (const path of [ + '/api/merchant/v1/last-leg/orders', + '/api/merchant/v1/last-leg/relabel-applications', + '/api/merchant/v1/last-leg/warehouse-transfers', + '/api/merchant/v1/last-leg/cargo-discards', + 'inventory-candidates', + ]) assert.ok(source.includes(path), `missing ${path}`) + for (const forbidden of ['/api/pda/', '/api/platform/v1/last-leg/dispatches', '/outbound', '/delivery', '/dispatch', '/depart']) { + assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) + } +}) + +test('module never borrows historical order or inventory candidates', () => { + assert.match(source, /fixture\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.marker !== context\.marker/) + assert.match(source, /pda\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /completedContainers/) + assert.match(source, /markerOrderIds\.has\(id\(value\?\.orderId\)\)/) + assert.match(source, /禁止借历史一件代发库存/) + assert.doesNotMatch(source, /candidates\[0\]/) +}) + +test('relabel transfer and discard cover create replay conflict list detail and legal initial states', () => { + for (const token of [ + '客户创建贴标申请', '客户贴标申请幂等重放', '贴标同幂等键异载荷返回A0409', + '客户查询自身贴标申请列表', '客户查询自身贴标申请详情', 'PENDING_PROCESSING', + '客户创建仓储转运申请', '客户仓储转运申请幂等重放', '转运同幂等键异载荷返回A0409', + '客户查询自身仓储转运列表', '客户查询自身仓储转运详情', + '客户创建货物废弃申请', '客户货物废弃申请幂等重放', + '客户查询自身货物废弃列表', '客户查询自身货物废弃详情', 'PENDING_REVIEW', + ]) assert.ok(source.includes(token), `missing ${token}`) +}) + +test('customer isolation is exercised with merchant token, forged customerId and platform denial', () => { + assert.match(source, /merchantQualificationFixture/) + assert.match(source, /overseasAccessToken/) + assert.match(source, /customerId: 0/) + assert.match(source, /平台海外Token不能冒充客户访问/) + assert.match(source, /response\?\.http === 403.*codeOf\(response\) === 'A0403'/s) +}) + +test('module records marker-owned resources without forbidden fulfillment cleanup', () => { + assert.match(source, /createdResources\.phase4OverseasClientServices/) + assert.match(source, /relabelNo = relabelNo/) + assert.match(source, /transferNo = transferNo/) + assert.match(source, /discardNo = discardNo/) + assert.match(source, /no outbound, dispatch, PDA or delivery action is invoked/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs new file mode 100644 index 000000000..e887bc8bf --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -0,0 +1,253 @@ +const MODULE = 'phase4-overseas-client-services' +const SCENARIO = 'phase4-overseas-client-services-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const ORDERS = '/api/merchant/v1/last-leg/orders' +const RELABEL = '/api/merchant/v1/last-leg/relabel-applications' +const TRANSFER = '/api/merchant/v1/last-leg/warehouse-transfers' +const DISCARD = '/api/merchant/v1/last-leg/cargo-discards' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function forbidden(response) { return response?.http === 403 || (response?.http === 200 && codeOf(response) === 'A0403') } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function future(days) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19) } + +async function merchant(context, token, method, name, path, body) { + const response = await context.requestOverseas(method, path, body, {}, token) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} + +async function loginMerchant(context) { + const fixture = context.context?.merchantQualificationFixture + const pda = context.createdResources?.phase4OverseasPda + if (!fixture?.username || !fixture?.password || fixture?.marker !== context.marker + || pda?.marker !== context.marker || pda?.finalStatus !== 'COMPLETED') { + blocker(context, '取得本轮客户账号与海外PDA库存前置', + `merchantMarker=${fixture?.marker || 'missing'}, pdaMarker=${pda?.marker || 'missing'}, pdaStatus=${pda?.finalStatus || 'missing'}`) + return null + } + const response = await context.request('POST', '/api/auth/login', { + username: fixture.username, password: fixture.password, + }, '') + const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS) + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), + `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(auth?.overseasAccessToken) ? auth.overseasAccessToken : null +} + +async function currentOrders(context, token) { + const pda = context.createdResources.phase4OverseasPda + const containerNos = [...new Set([...(pda.completedContainers || []), pda.containerNo].map(id).filter(Boolean))] + const result = [] + for (const containerNo of containerNos) { + const page = await merchant(context, token, 'GET', `查询本轮完成柜${containerNo}的客户尾程订单`, + query(ORDERS, { page: 1, size: 200, containerNo })) + for (const row of records(page.data)) { + if (id(row?.containerNo) !== containerNo || !id(row?.id)) continue + const detail = await merchant(context, token, 'GET', `读取本轮尾程订单${id(row.id)}详情`, + `${ORDERS}/${encodeURIComponent(id(row.id))}`) + if (success(detail.response) && id(detail.data?.order?.id) === id(row.id)) { + result.push({ row: detail.data.order, cargo: Array.isArray(detail.data?.cargo) ? detail.data.cargo : [] }) + } + } + } + const ownedIds = new Set(result.map((value) => id(value.row?.id))) + context.assert(MODULE, '客户Token只取得本轮PDA完成柜的自身尾程事实', null, + result.length > 0 && (!pda.orderId || ownedIds.has(id(pda.orderId))), + `containers=${containerNos.join(',') || 'missing'}, orders=${[...ownedIds].join(',') || 'missing'}`, OPTIONS) + return result +} + +function usableCargo(order) { + return order.cargo.find((item) => nonBlank(item?.skuCode) + && nonBlank(item?.productNameZh) && Number(item?.packageCount) > 0) +} + +async function verifyRelabel(context, token, order) { + const cargo = usableCargo(order) + if (!cargo || !nonBlank(order.row?.overseasWarehouseId)) { + blocker(context, '本轮贴标申请候选', + `orderId=${id(order.row?.id) || 'missing'}, sku=${cargo?.skuCode || 'missing'}, warehouse=${order.row?.overseasWarehouseId || 'missing'}`) + return false + } + const body = { + customerOrderNo: order.row.orderNo, + overseasWarehouseId: order.row.overseasWarehouseId, + labelDeadline: future(7), contactName: '客户贴标验收联系人', contactPhone: '13800000006', + remark: context.marker, idempotencyKey: `${context.marker}-CLIENT-RELABEL`.slice(0, 255), + items: [{ + skuCode: cargo.skuCode, productImageFileId: null, productImageFileName: null, productImageUrl: null, + productNameZh: cargo.productNameZh, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: cargo.shipmentNo || `${context.marker}-RELABEL-SHIP`, trackingCode: cargo.fbaTrackingNo || null, + newSkuCode: `${cargo.skuCode}-NEW`.slice(0, 128), labelRequirement: context.marker, + relabelFileId: null, relabelFileName: null, relabelFileUrl: null, + }], + } + const created = await merchant(context, token, 'POST', '客户创建贴标申请', RELABEL, body) + const relabelNo = id(created.data?.application?.relabelNo) + context.assert(MODULE, '客户贴标申请进入合法待处理状态', created.response, + Boolean(relabelNo) && created.data?.application?.status === 'PENDING_PROCESSING' + && created.data?.application?.customerOrderNo === order.row.orderNo, + `relabelNo=${relabelNo || 'missing'}, status=${created.data?.application?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!relabelNo) return false + const replay = await merchant(context, token, 'POST', '客户贴标申请幂等重放', RELABEL, body) + context.assert(MODULE, '贴标重放复用同一申请', replay.response, + id(replay.data?.application?.relabelNo) === relabelNo, trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('POST', RELABEL, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + context.addStep(MODULE, '贴标同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身贴标申请列表', + query(RELABEL, { page: 1, size: 20, relabelNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变贴标客户范围', page.response, + records(page.data).some((item) => id(item?.relabelNo) === relabelNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身贴标申请详情', + `${RELABEL}/${encodeURIComponent(relabelNo)}`) + context.assert(MODULE, '贴标详情保留明细与创建历史', detail.response, + id(detail.data?.application?.relabelNo) === relabelNo && detail.data?.application?.status === 'PENDING_PROCESSING' + && Array.isArray(detail.data?.items) && detail.data.items.length === 1 + && Array.isArray(detail.data?.history) && detail.data.history.some((item) => item?.toStatus === 'PENDING_PROCESSING'), + `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.history?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.relabelNo = relabelNo + return true +} + +async function verifyTransfer(context, token, orders) { + const order = orders.find((value) => nonBlank(value.row?.inboundCode) + && nonBlank(value.row?.fbaWarehouseCode) && nonBlank(value.row?.overseasWarehouseId) && usableCargo(value)) + const cargo = order ? usableCargo(order) : null + if (!order || !cargo) { + blocker(context, '本轮仓储转运申请候选', + `orders=${orders.length}, fbaOrders=${orders.filter((value) => nonBlank(value.row?.fbaWarehouseCode)).length}; 禁止借历史入仓单`) + return false + } + const packages = 1 + const volume = Number(cargo.volumeCbm) > 0 ? Number(cargo.volumeCbm) : 0.0001 + const body = { + inboundCode: order.row.inboundCode, sourceLastlegOrderIds: [id(order.row.id)], + operatingWarehouseId: order.row.overseasWarehouseId, fbaWarehouseCode: order.row.fbaWarehouseCode, + deliveryAddress: `${order.row.fbaWarehouseCode} ${context.marker}`.slice(0, 1000), + packageCount: packages, volumeCbm: volume, deliveryMethod: 'TRUCK', estimatedArrivalAt: future(10), + fbaShipmentNo: cargo.shipmentNo || `${context.marker}-TRANSFER-SHIP`, trackingNo: cargo.fbaTrackingNo || null, + fbaBoxNos: cargo.shippingMark || null, remark: context.marker, needRelabel: false, + idempotencyKey: `${context.marker}-CLIENT-TRANSFER`.slice(0, 255), + items: [{ skuCode: cargo.skuCode, productNameZh: cargo.productNameZh, + packageCount: packages, shippingMark: cargo.shippingMark || null }], + } + const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) + const transferNo = id(created.data?.transferNo) + context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, + Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' + && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), + `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!transferNo) return false + const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) + context.assert(MODULE, '转运重放复用同一申请', replay.response, + id(replay.data?.transferNo) === transferNo, trace(replay.response), OPTIONS) + const hashConflict = await context.requestOverseas('POST', TRANSFER, { ...body, remark: `${context.marker}-DIFFERENT` }, {}, token) + context.addStep(MODULE, '转运同幂等键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身仓储转运列表', + query(TRANSFER, { page: 1, size: 20, transferNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变转运客户范围', page.response, + records(page.data).some((item) => id(item?.transferNo) === transferNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', + `${TRANSFER}/${encodeURIComponent(transferNo)}`) + context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, + id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' + && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, + `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.transferNo = transferNo + return true +} + +async function verifyDiscard(context, token, markerOrders) { + const response = await context.requestOverseas('GET', `${DISCARD}/inventory-candidates`, undefined, {}, token) + const candidates = context.apiSuccess(MODULE, '客户查询货物废弃库存候选', response, trace(response), OPTIONS) + const markerOrderIds = new Set(markerOrders.map((value) => id(value.row?.id))) + const candidate = Array.isArray(candidates) ? candidates.find((value) => markerOrderIds.has(id(value?.orderId)) + && Array.isArray(value?.skus) && value.skus.some((sku) => nonBlank(sku?.skuCode) && Number(sku?.availablePackages) > 0)) : null + if (!candidate) { + blocker(context, '本轮货物废弃库存候选', + `candidateOrders=${Array.isArray(candidates) ? candidates.map((value) => id(value?.orderId)).join(',') : 'missing'}, markerOrders=${[...markerOrderIds].join(',') || 'missing'}; 禁止借历史一件代发库存`, response) + return false + } + const sku = candidate.skus.find((value) => nonBlank(value?.skuCode) && Number(value?.availablePackages) > 0) + const body = { + items: [{ orderId: id(candidate.orderId), skuCode: sku.skuCode, discardPackages: 1 }], + idempotencyKey: `${context.marker}-CLIENT-DISCARD`.slice(0, 255), remark: context.marker, + } + const created = await merchant(context, token, 'POST', '客户创建货物废弃申请', DISCARD, body) + const discardNo = id(created.data?.summary?.discardNo) + context.assert(MODULE, '废弃申请只锁定库存并进入合法待审核状态', created.response, + Boolean(discardNo) && created.data?.summary?.status === 'PENDING_REVIEW' + && Number(created.data?.summary?.totalPackages) === 1, + `discardNo=${discardNo || 'missing'}, status=${created.data?.summary?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + if (!discardNo) return false + const replay = await merchant(context, token, 'POST', '客户货物废弃申请幂等重放', DISCARD, body) + context.assert(MODULE, '废弃重放复用同一申请', replay.response, + id(replay.data?.summary?.discardNo) === discardNo, trace(replay.response), OPTIONS) + const page = await merchant(context, token, 'GET', '客户查询自身货物废弃列表', + query(DISCARD, { page: 1, size: 20, discardNo, customerId: 0 })) + context.assert(MODULE, '伪造customerId不改变废弃客户范围', page.response, + records(page.data).some((item) => id(item?.discardNo) === discardNo), trace(page.response), OPTIONS) + const detail = await merchant(context, token, 'GET', '客户查询自身货物废弃详情', + `${DISCARD}/${encodeURIComponent(discardNo)}`) + context.assert(MODULE, '废弃详情保留候选快照和创建历史且未审核扣减', detail.response, + id(detail.data?.summary?.discardNo) === discardNo && detail.data?.summary?.status === 'PENDING_REVIEW' + && Array.isArray(detail.data?.items) && detail.data.items.length === 1 + && Array.isArray(detail.data?.histories) && detail.data.histories.some((item) => item?.toStatus === 'PENDING_REVIEW'), + `items=${detail.data?.items?.length ?? 'missing'}, histories=${detail.data?.histories?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + context.createdResources.phase4OverseasClientServices.discardNo = discardNo + return true +} + +export async function run(context) { + context.createdResources.phase4OverseasClientServices = { + marker: context.marker, relabelNo: null, transferNo: null, discardNo: null, + finalStatuses: { relabel: null, transfer: null, discard: null }, + cleanup: 'Merchant applications remain in their legal initial review/processing states; no outbound, dispatch, PDA or delivery action is invoked', + } + const token = await loginMerchant(context) + if (!token) return + + for (const [name, path] of [['贴标', RELABEL], ['仓储转运', TRANSFER], ['货物废弃', DISCARD]]) { + const denied = await context.requestOverseas('GET', query(path, { page: 1, size: 1 })) + context.addStep(MODULE, `平台海外Token不能冒充客户访问${name}`, denied, forbidden(denied), trace(denied), OPTIONS) + } + + const orders = await currentOrders(context, token) + if (orders.length === 0) { + blocker(context, '取得本轮客户尾程与库存事实', '客户Token未查到本轮phase4 PDA完成柜,禁止借历史订单') + return + } + const relabelOrder = orders.find((value) => usableCargo(value) && nonBlank(value.row?.overseasWarehouseId)) + if (!relabelOrder) { + blocker(context, '本轮贴标订单明细', `orders=${orders.length}; 缺少真实SKU/品名/箱数或实际海外仓`) + return + } + const relabelDone = await verifyRelabel(context, token, relabelOrder) + const transferDone = await verifyTransfer(context, token, orders) + const discardDone = await verifyDiscard(context, token, orders) + context.createdResources.phase4OverseasClientServices.finalStatuses = { + relabel: relabelDone ? 'PENDING_PROCESSING' : null, + transfer: transferDone ? 'PENDING_REVIEW' : null, + discard: discardDone ? 'PENDING_REVIEW' : null, + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs new file mode 100644 index 000000000..541b1438d --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' + +const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) +const source = await readFile(fileURLToPath(moduleUrl), 'utf8') + +test('phase4 overseas dispatch module freezes only stable platform query and smart pre-arrange paths', () => { + for (const token of [ + "const BASE = '/api/platform/v1/last-leg/dispatches'", + "query(BASE, { page: 1, size: 20 })", + '`${BASE}/candidates`', '`${BASE}/drafts`', '`${BASE}/drafts/snapshot`', + '`${BASE}/draft-locks/lock`', '`${BASE}/pre-arrange`', '`${BASE}/draft-locks/release`', + '`${BASE}/${encodeURIComponent(dispatchNo)}`', + ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/depart', '/cancel']) { + assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) + } +}) + +test('module uses marker-owned completed PDA pallets and never borrows historical candidates', () => { + assert.match(source, /fixture\?\.marker !== context\.marker/) + assert.match(source, /fixture\?\.finalStatus !== 'COMPLETED'/) + assert.match(source, /fixture\.palletIds\.has\(id\(value\?\.warehousePalletId\)\)/) + assert.match(source, /id\(value\?\.overseasWarehouseId\) === id\(fixture\.overseasWarehouseId\)/) + assert.doesNotMatch(source, /candidates\[0\]/) +}) + +test('module proves draft idempotency, lock isolation, stale replay conflict and readback', () => { + assert.match(source, /相同草稿快照重复保存/) + assert.match(source, /同草稿重复锁定托盘幂等成功/) + assert.match(source, /其他草稿锁定同一托盘返回A0409/) + assert.match(source, /已转换正式锁的旧草稿载荷再次预排返回A0409/) + assert.match(source, /重复释放草稿幂等成功/) + assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) + assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) +}) + +test('module does not invent optimistic version or idempotency headers absent from DispatchRequests', () => { + assert.doesNotMatch(source, /expectedVersion|recordVersion|Idempotency-Key/) + assert.match(source, /PENDING_DEPARTURE/) + assert.match(source, /data == null/) +}) + +test('module records the marker-owned dispatch without attempting excluded cleanup actions', () => { + assert.match(source, /createdResources\.phase4OverseasDispatch/) + assert.match(source, /dispatchNo = dispatchNo/) + assert.match(source, /finalStatus = arranged\.data\?\.status/) + assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs new file mode 100644 index 000000000..48aefddd7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -0,0 +1,169 @@ +const MODULE = 'phase4-overseas-dispatch' +const SCENARIO = 'phase4-overseas-dispatch-lifecycle' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const BASE = '/api/platform/v1/last-leg/dispatches' + +function id(value) { return value == null ? '' : String(value) } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } +function success(response) { return response?.http === 200 && codeOf(response) === '200' } +function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values || {})) { + if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value)) + } + const encoded = params.toString() + return encoded ? `${path}?${encoded}` : path +} + +async function api(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} + +function blocker(context, name, evidence, response = null) { + context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) +} + +function markerFixture(context) { + const fixture = context.createdResources?.phase4OverseasPda + if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null + const palletIds = new Set((fixture.palletIds || []).map(id).filter(Boolean)) + if (!fixture.overseasWarehouseId || palletIds.size === 0) return null + return { ...fixture, palletIds } +} + +export async function run(context) { + context.createdResources.phase4OverseasDispatch = { + marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, + planPalletId: null, finalStatus: null, + cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', + } + + const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 })) + context.assert(MODULE, '排车分页包络稳定', initialPage.response, + Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size)) + && Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records), + `records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS) + if (!success(initialPage.response)) return + + const fixture = markerFixture(context) + if (!fixture) { + blocker(context, '取得本轮海外PDA已收货托盘前置', + `marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) + return + } + + const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] + const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId)) + && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) + && id(value?.planId) && id(value?.planPalletId)) + context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response, + Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0, + `candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) + if (!candidate) return + + const planId = id(candidate.planId) + const planPalletId = id(candidate.planPalletId) + const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) + const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) + const snapshotBody = { draftToken, planIds: [planId], containersJson } + context.createdResources.phase4OverseasDispatch.draftToken = draftToken + context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId + + const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) + context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, + success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS) + if (!success(snapshot.response)) return + const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody) + if (!success(snapshotReplay.response)) return + + const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`) + const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] + const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) + context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, + matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId) + && matchingDrafts[0].containersJson === containersJson, + `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) + + const lockBody = { planPalletId, draftToken } + const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody) + if (!success(locked.response)) return + const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) + context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, + success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) + + const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) + const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId) + context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, + `planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS) + + const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', + query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) + const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId) + context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, + `planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS) + + const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) + const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, { + planPalletId, draftToken: foreignToken, + }) + context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) + + const preArrangeBody = { + planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null, + fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, + } + const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) + const dispatchNo = id(arranged.data?.dispatchNo) + context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, + Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' + && Array.isArray(arranged.data?.items) + && arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId) + && Number(arranged.data?.palletCount) === 1, + `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS) + if (!dispatchNo) return + context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo + context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) + context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null + + const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody) + context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange, + conflict(duplicateArrange), trace(duplicateArrange), OPTIONS) + + const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { + page: 1, size: 20, dispatchNo, + })) + const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo) + context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response, + row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id), + `dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS) + + const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) + context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response, + id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE' + && Array.isArray(detailResult.data?.items) + && detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId), + `dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS) + + const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken }) + if (success(released.response)) { + const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken }) + context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response, + success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS) + } + + const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`) + context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response, + Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken), + `draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs new file mode 100644 index 000000000..4f275d6a2 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations-contract.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-inbound-operations.mjs'), 'utf8') + +test('module covers the frozen Admin inbound-plan write surface', () => { + for (const value of ['/convert', '/pallets/supplement', '/fba-appointments/batch', + '/fba-appointments/${encodeURIComponent(appointmentNo)}']) assert.ok(source.includes(value), value) + assert.match(source, /重复转换返回同一入仓计划/) + assert.match(source, /FBA预约同单号异载荷返回A0409/) + assert.match(source, /appointmentStatus: 'CANCELLED'/) +}) + +test('module covers reversible inventory commands with idempotency and ledger proof', () => { + for (const endpoint of ['/hold', '/release', '/adjust', '/ledger']) assert.ok(source.includes(endpoint), endpoint) + assert.match(source, /PDA完成柜形成的库存/) + assert.match(source, /库存冻结同键异载荷返回A0409/) + assert.match(source, /盘点调整同键异载荷返回A0409/) + assert.match(source, /库存数量恢复基线/) + assert.match(source, /types\.filter\(\(type\) => type === 'ADJUST'\)\.length === 2/) +}) + +test('module stays inside inbound and inventory scope', () => { + for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/dispatch', '/ship', '/pick']) { + assert.equal(source.toLowerCase().includes(forbidden), false, forbidden) + } + assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|https?:\/\//) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs new file mode 100644 index 000000000..87bc9f03e --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -0,0 +1,212 @@ +const MODULE = 'phase4-overseas-inbound-operations' +const OPTIONS = { scenario: 'phase4-inbound-admin-and-inventory-commands', deploymentBlocker: true } +const PLAN_BASE = '/api/platform/v1/last-leg/inbound-plans' +const INVENTORY_BASE = '/api/platform/v1/last-leg/inventory' + +function id(value) { return value == null ? '' : String(value) } +function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function rows(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] } +function conflict(response) { return response?.http === 200 && ['A0409', '409'].includes(String(response?.body?.code)) } +function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) if (value != null && value !== '') params.set(name, String(value)) + return `${path}?${params.toString()}` +} +function key(context, operation) { return `${context.marker}:P4-INBOUND:${operation}`.slice(0, 255) } + +async function request(context, method, name, path, body) { + const response = await context.requestOverseas(method, path, body) + context.addStep(MODULE, name, response, ok(response), trace(response), OPTIONS) + return ok(response) ? response.body?.data : null +} + +async function inboundAdminLifecycle(context) { + const matrix = context.createdResources?.phase4InboundProducerFixtures + const completed = new Set((context.createdResources?.phase4OverseasPda?.completedContainers || []).map(id)) + const producer = (matrix?.byDestinationType?.FBA || []).find((item) => !completed.has(id(item?.containerNo))) + context.assert(MODULE, '选择本轮未被PDA完成的FBA第二柜作为Admin写链夹具', null, + Boolean(producer?.containerNo && producer?.tenantId), + `containerNo=${producer?.containerNo || 'missing'}, customerId=${producer?.tenantId || 'missing'}`, OPTIONS) + if (!producer?.containerNo || !producer?.tenantId) return + + const customerId = id(producer.tenantId) + const orderPage = await request(context, 'GET', '按第二柜查询尾程订单', query('/api/platform/v1/last-leg/orders', { + page: 1, size: 200, customerId, containerNo: producer.containerNo, + })) + const order = rows(orderPage).find((item) => id(item?.customerId) === customerId + && id(item?.containerNo) === id(producer.containerNo) && item?.destinationType === 'FBA') + context.assert(MODULE, '第二柜尾程订单身份稳定', null, Boolean(order?.id), + `orderId=${id(order?.id) || 'missing'}, containerNo=${producer.containerNo}`, OPTIONS) + if (!order?.id) return + + const convertBody = { orderId: id(order.id), customerId } + const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody) + const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody) + context.assert(MODULE, '入仓计划转换天然幂等', null, + Boolean(plan?.plan?.planNo) && replay?.plan?.planNo === plan.plan.planNo, + `first=${plan?.plan?.planNo || 'missing'}, replay=${replay?.plan?.planNo || 'missing'}`, OPTIONS) + const planNo = plan?.plan?.planNo + const fbaLines = (plan?.lines || []).filter((line) => line?.destinationType === 'FBA' + && line?.fbaWarehouseCode && line?.destinationWarehouseId) + context.assert(MODULE, 'FBA入仓计划包含可预约明细', null, + Boolean(planNo) && fbaLines.length > 0, + `planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS) + if (!planNo || fbaLines.length === 0) return + + const sample = fbaLines[0] + const supplement = await request(context, 'POST', '为未完成FBA计划补充可打印托盘', + `${PLAN_BASE}/${encodeURIComponent(planNo)}/pallets/supplement`, { + destinationWarehouseId: id(sample.destinationWarehouseId), + destinationWarehouseCode: sample.destinationWarehouseCode, + destinationWarehouseName: sample.destinationWarehouseName, + customerId, + }) + context.assert(MODULE, '补充托盘绑定计划内目的仓且保持未分配', null, + Boolean(supplement?.id) && supplement?.assignmentStatus === 'UNASSIGNED' + && id(supplement?.destinationWarehouseId) === id(sample.destinationWarehouseId), + `palletId=${id(supplement?.id) || 'missing'}, status=${supplement?.assignmentStatus || 'missing'}`, OPTIONS) + + const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const appointmentNo = `FBA-${suffix}`.slice(0, 64) + const now = Date.now() + const appointmentBody = { + customerId, lineIds: fbaLines.map((line) => id(line.id)), appointmentNo, + appointmentAt: new Date(now + 24 * 60 * 60_000).toISOString().slice(0, 19), + expectedLoadingAt: new Date(now + 26 * 60 * 60_000).toISOString().slice(0, 19), + appointmentStatus: 'PENDING', appointmentRemark: context.marker, + } + const appointment = await request(context, 'POST', '批量创建FBA预约', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody) + const appointmentReplay = await request(context, 'POST', 'FBA预约同单号同载荷重放', `${PLAN_BASE}/fba-appointments/batch`, appointmentBody) + context.assert(MODULE, 'FBA预约重放返回同一聚合且明细一致', null, + id(appointmentReplay?.id) === id(appointment?.id) + && appointmentReplay?.items?.length === fbaLines.length, + `appointmentId=${id(appointment?.id) || 'missing'}, items=${appointmentReplay?.items?.length ?? 'missing'}`, OPTIONS) + const appointmentConflict = await context.requestOverseas('POST', `${PLAN_BASE}/fba-appointments/batch`, { + ...appointmentBody, appointmentRemark: `${context.marker}-different`, + }) + context.addStep(MODULE, 'FBA预约同单号异载荷返回A0409', appointmentConflict, + conflict(appointmentConflict), trace(appointmentConflict), OPTIONS) + const detail = await request(context, 'GET', '查询FBA预约详情', query( + `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { customerId })) + context.assert(MODULE, 'FBA预约详情冻结仓码和所选明细', null, + detail?.appointmentNo === appointmentNo && detail?.fbaWarehouseCode === sample.fbaWarehouseCode + && detail?.items?.length === fbaLines.length, + `appointmentNo=${detail?.appointmentNo || 'missing'}, warehouse=${detail?.fbaWarehouseCode || 'missing'}`, OPTIONS) + const cancelled = await request(context, 'PUT', '取消本轮FBA预约收口', + `${PLAN_BASE}/fba-appointments/${encodeURIComponent(appointmentNo)}`, { + customerId, appointmentAt: appointmentBody.appointmentAt, + expectedLoadingAt: appointmentBody.expectedLoadingAt, + appointmentStatus: 'CANCELLED', appointmentRemark: `${context.marker}-cancelled`, + }) + context.assert(MODULE, 'FBA预约最终为CANCELLED', null, + cancelled?.appointmentStatus === 'CANCELLED', `status=${cancelled?.appointmentStatus || 'missing'}`, OPTIONS) + context.createdResources.phase4OverseasInboundAdmin = { + marker: context.marker, customerId, orderId: id(order.id), planNo, + supplementedPalletId: id(supplement?.id) || null, appointmentNo, + terminalStatus: cancelled?.appointmentStatus || null, + } +} + +async function inventoryCommandLifecycle(context) { + const pda = context.createdResources?.phase4OverseasPda + const customerId = id(pda?.customerId) + const palletIds = new Set((pda?.palletIds || []).map(id)) + context.assert(MODULE, '库存命令只使用本轮PDA完成柜形成的库存', null, + Boolean(customerId && pda?.finalStatus === 'COMPLETED' && palletIds.size > 0), + `customerId=${customerId || 'missing'}, pallets=${palletIds.size}, status=${pda?.finalStatus || 'missing'}`, OPTIONS) + if (!customerId || pda?.finalStatus !== 'COMPLETED' || palletIds.size === 0) return + + const balancePage = await request(context, 'GET', '查询本轮客户库存余额', query(`${INVENTORY_BASE}/balances`, { + customerId, overseasWarehouseId: pda.overseasWarehouseId, page: 1, size: 200, + })) + let selected = null + for (const balance of rows(balancePage)) { + const positions = await request(context, 'GET', `查询库存余额${id(balance.id)}库位`, query( + `${INVENTORY_BASE}/balances/${encodeURIComponent(id(balance.id))}/positions`, { customerId })) + const position = rows(positions).find((item) => palletIds.has(id(item?.palletId)) + && Number(item?.availablePackages) >= 1) + if (position) { selected = { balance, position }; break } + } + context.assert(MODULE, '定位本轮PDA托盘上的可用库存位置', null, Boolean(selected?.position?.id), + `positionId=${id(selected?.position?.id) || 'missing'}, palletId=${id(selected?.position?.palletId) || 'missing'}`, OPTIONS) + if (!selected?.position?.id) return + + const positionId = id(selected.position.id) + const baseline = { + available: Number(selected.position.availablePackages), hold: Number(selected.position.holdPackages), + allocated: Number(selected.position.allocatedPackages), picked: Number(selected.position.pickedPackages), + } + const businessOrderNo = `${context.marker}-INVENTORY` + const holdBody = { positionId, packages: 1, businessOrderNo, + idempotencyKey: key(context, 'HOLD'), remark: context.marker } + const held = await request(context, 'POST', '冻结一箱本轮PDA库存', `${INVENTORY_BASE}/hold`, holdBody) + const heldReplay = await request(context, 'POST', '库存冻结同键重放', `${INVENTORY_BASE}/hold`, holdBody) + context.assert(MODULE, '库存冻结重放不重复扣减', null, + held?.operationType === 'HOLD' && heldReplay?.operationId === held?.operationId && heldReplay?.replayed === true, + `operationId=${id(held?.operationId) || 'missing'}, replayed=${heldReplay?.replayed}`, OPTIONS) + const holdConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/hold`, { + ...holdBody, remark: `${context.marker}-different`, + }) + context.addStep(MODULE, '库存冻结同键异载荷返回A0409', holdConflict, + conflict(holdConflict), trace(holdConflict), OPTIONS) + + const releaseBody = { positionId, packages: 1, businessOrderNo, + idempotencyKey: key(context, 'RELEASE'), remark: context.marker } + const released = await request(context, 'POST', '释放一箱冻结库存', `${INVENTORY_BASE}/release`, releaseBody) + const releasedReplay = await request(context, 'POST', '库存释放同键重放', `${INVENTORY_BASE}/release`, releaseBody) + context.assert(MODULE, '库存释放重放不重复增加可用数', null, + released?.operationType === 'RELEASE' && releasedReplay?.operationId === released?.operationId + && releasedReplay?.replayed === true, + `operationId=${id(released?.operationId) || 'missing'}, replayed=${releasedReplay?.replayed}`, OPTIONS) + + const increaseBody = { positionId, packageDelta: 1, reason: 'ACCEPTANCE_COUNT_GAIN', + businessOrderNo, idempotencyKey: key(context, 'ADJUST-UP'), remark: context.marker } + const increased = await request(context, 'POST', '盘点调增一箱', `${INVENTORY_BASE}/adjust`, increaseBody) + const increaseReplay = await request(context, 'POST', '盘点调增同键重放', `${INVENTORY_BASE}/adjust`, increaseBody) + context.assert(MODULE, '盘点调增重放不重复入账', null, + increased?.operationType === 'ADJUST' && increaseReplay?.operationId === increased?.operationId + && increaseReplay?.replayed === true, + `operationId=${id(increased?.operationId) || 'missing'}, replayed=${increaseReplay?.replayed}`, OPTIONS) + const adjustConflict = await context.requestOverseas('POST', `${INVENTORY_BASE}/adjust`, { + ...increaseBody, reason: 'DIFFERENT_COUNT_REASON', + }) + context.addStep(MODULE, '盘点调整同键异载荷返回A0409', adjustConflict, + conflict(adjustConflict), trace(adjustConflict), OPTIONS) + await request(context, 'POST', '盘点调减一箱恢复基线', `${INVENTORY_BASE}/adjust`, { + positionId, packageDelta: -1, reason: 'ACCEPTANCE_COUNT_RESTORE', businessOrderNo, + idempotencyKey: key(context, 'ADJUST-DOWN'), remark: context.marker, + }) + + const finalPositions = await request(context, 'GET', '回查库存命令后的托盘位置', query( + `${INVENTORY_BASE}/balances/${encodeURIComponent(id(selected.balance.id))}/positions`, { customerId })) + const finalPosition = rows(finalPositions).find((item) => id(item?.id) === positionId) + context.assert(MODULE, '冻结释放与盘点正反向后库存数量恢复基线', null, + Number(finalPosition?.availablePackages) === baseline.available + && Number(finalPosition?.holdPackages) === baseline.hold + && Number(finalPosition?.allocatedPackages) === baseline.allocated + && Number(finalPosition?.pickedPackages) === baseline.picked, + `before=${JSON.stringify(baseline)}, after=${JSON.stringify({ available: finalPosition?.availablePackages, + hold: finalPosition?.holdPackages, allocated: finalPosition?.allocatedPackages, + picked: finalPosition?.pickedPackages })}`, OPTIONS) + const ledger = await request(context, 'GET', '按本轮业务号回查库存流水', query(`${INVENTORY_BASE}/ledger`, { + customerId, businessOrderNo, page: 1, size: 100, + })) + const types = rows(ledger).map((item) => item?.businessType) + context.assert(MODULE, '库存流水包含HOLD、RELEASE及两条ADJUST且重放零重复', null, + types.filter((type) => type === 'HOLD').length === 1 + && types.filter((type) => type === 'RELEASE').length === 1 + && types.filter((type) => type === 'ADJUST').length === 2, + `types=${types.join(',') || 'missing'}`, OPTIONS) + context.createdResources.phase4OverseasInventoryCommands = { + marker: context.marker, customerId, balanceId: id(selected.balance.id), positionId, + terminalState: 'RESTORED', + } +} + +export async function run(context) { + context.createdResources.phase4OverseasInboundAdmin = { marker: context.marker } + context.createdResources.phase4OverseasInventoryCommands = { marker: context.marker } + await inboundAdminLifecycle(context) + await inventoryCommandLifecycle(context) +} diff --git a/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs new file mode 100644 index 000000000..d80e26117 --- /dev/null +++ b/scripts/admin-test-env-modules/supplemental-boundaries-contract.test.mjs @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./supplemental-boundaries.mjs', import.meta.url), 'utf8') + +test('occupied location deletion protection reuses only the marker-owned loading fixture', () => { + assert.match(source, /phase3Loading/) + assert.match(source, /inventoryMutationCleanup\?\.targetLocationId/) + assert.match(source, /loading\?\.marker === context\.marker/) + assert.match(source, /真实占用库位删除返回A0409且保留资源/) + assert.match(source, /占用删除冲突未造成资源丢失/) + assert.doesNotMatch(source, /no-public-fixture-api|当前公共验收 API 未提供/) +}) diff --git a/scripts/admin-test-env-modules/supplemental-boundaries.mjs b/scripts/admin-test-env-modules/supplemental-boundaries.mjs index 7ca47c5ae..a6057b65d 100644 --- a/scripts/admin-test-env-modules/supplemental-boundaries.mjs +++ b/scripts/admin-test-env-modules/supplemental-boundaries.mjs @@ -86,11 +86,30 @@ export async function run(context) { locationVersion = Number(afterData?.location?.version) } - // Public APIs do not expose a safe way to create a real occupied pallet/ - // location association without an inbound allocation fixture. Keep this as - // an explicit automation gap instead of claiming a deletion-protection pass. - context.addCoverageGap(MODULE, 'resource-occupancy', '托盘/库位真实占用删除保护', - '当前公共验收 API 未提供可安全构造并释放占用关联的独立 fixture;未操作历史资源', 'no-public-fixture-api') + // phase3-loading has already created and rebound a marker-owned live + // occupancy. Reuse that exact location to prove deletion protection without + // touching historical resources or inventing a setup-only API. + const loading = context.createdResources?.phase3Loading + const occupiedLocationId = id(loading?.inventoryMutationCleanup?.targetLocationId) + const occupiedWarehouseId = id(loading?.warehouseId) + context.assert(MODULE, '取得本轮装柜链真实占用库位', null, + Boolean(occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker), + `locationId=${occupiedLocationId || 'missing'}, warehouseId=${occupiedWarehouseId || 'missing'}`, OPTIONS) + if (occupiedLocationId && occupiedWarehouseId && loading?.marker === context.marker) { + const occupiedPath = `/api/platform/v1/locations/${occupiedLocationId}?tenantId=0&warehouseId=${occupiedWarehouseId}` + const occupiedDetail = await context.request('GET', occupiedPath) + const occupied = context.apiSuccess(MODULE, '读取本轮真实占用库位版本', occupiedDetail, '', OPTIONS) + const occupiedVersion = Number(occupied?.location?.version) + const rejectedDelete = await context.request('DELETE', `${occupiedPath}&version=${occupiedVersion}`) + context.addStep(MODULE, '真实占用库位删除返回A0409且保留资源', rejectedDelete, + rejectedDelete.http === 200 && ['A0409', '409'].includes(codeOf(rejectedDelete)), + `HTTP=${rejectedDelete.http}, code=${codeOf(rejectedDelete)}, version=${occupiedVersion}`, OPTIONS) + const occupiedAfter = await context.request('GET', occupiedPath) + const after = context.apiSuccess(MODULE, '删除冲突后回查占用库位', occupiedAfter, '', OPTIONS) + context.assert(MODULE, '占用删除冲突未造成资源丢失', occupiedAfter, + id(after?.location?.id) === occupiedLocationId, + `expected=${occupiedLocationId}, actual=${id(after?.location?.id) || 'missing'}`, OPTIONS) + } // Import boundary probes use only in-memory bytes and dry-run where supported. const bogusXlsx = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0x00]) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index f4e1db7f6..0a1cf2a60 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -10,6 +10,9 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance for (const module of [ 'phase4-overseas-warehouse', 'phase4-overseas-pda', + 'phase4-overseas-inbound-operations', + 'phase4-overseas-client-services', + 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', @@ -19,6 +22,18 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance } }) +test('overseas inbound commands, client services and dispatch follow completed PDA receiving', () => { + for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch']) { + const modules = parseArguments(['--modules', consumer]).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', + 'phase3-loading-finalize', 'phase4-overseas-pda']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf(consumer)) + } + } +}) + test('entry accepts a separate overseas Gateway without exposing token state', () => { const options = parseArguments([ '--gateway', 'http://localhost:9000/', From 85a288e4f2f26f9d87c73168d2047ed22ac51a0d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:12:58 +0800 Subject: [PATCH 138/231] fix: preserve overseas pallet identity --- .../gateway/HttpWarehouseResourceGateway.java | 3 +- .../HttpWarehouseResourceGatewayTest.java | 61 +++++++++++++++++++ .../phase4-overseas-client-services.mjs | 8 +-- .../phase4-overseas-inbound-operations.mjs | 10 +-- 4 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java 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 62c41435f..7aff90d5b 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 @@ -1,5 +1,6 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; import org.springframework.http.HttpStatus; @@ -141,7 +142,7 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { } private record WarehousePalletResponse(WarehousePalletData data) {} - private record WarehousePalletData(long id, String palletCode, long warehouseId, long locationId, + private record WarehousePalletData(@JsonAlias("palletId") long id, String palletCode, long warehouseId, long locationId, String status, String fbaWarehouseCode, int version) {} private record ReserveEnvelope(ReserveData data) {} private record ReserveData(long lockId) {} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java new file mode 100644 index 000000000..188ff9c41 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class HttpWarehouseResourceGatewayTest { + private HttpWarehouseResourceGateway gateway; + private MockRestServiceServer server; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + gateway = new HttpWarehouseResourceGateway(); + ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); + ReflectionTestUtils.setField(gateway, "baseUrl", "http://warehouse"); + ReflectionTestUtils.setField(gateway, "internalToken", "test-token"); + gateway.initializeClient(); + } + + @Test + void resolveMapsCanonicalPalletIdInsteadOfSilentlyUsingZero() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-1")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":2092040891261427713,"palletCode":"PAL-1", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":"ABE3","version":3}} + """, MediaType.APPLICATION_JSON)); + + WarehouseResourceGateway.WarehousePalletReference result = gateway.resolvePallet("PAL-1"); + + assertThat(result.palletId()).isEqualTo(2092040891261427713L); + assertThat(result.warehouseId()).isEqualTo(2092040869757231105L); + assertThat(result.locationId()).isEqualTo(2092040891076878337L); + server.verify(); + } + + @Test + void ensureDropshipStillMapsWarehouseEntityId() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/ensure-dropship")) + .andRespond(withSuccess(""" + {"code":"200","data":{"id":2092040891261427713,"palletCode":"PAL-DROP", + "warehouseId":2092040869757231105,"locationId":2092040891076878337, + "status":"IDLE","fbaWarehouseCode":null,"version":1}} + """, MediaType.APPLICATION_JSON)); + + WarehouseResourceGateway.WarehousePalletReference result = + gateway.ensureDropshipPallet("2092040041967063041", 2092040869757231105L, "DROP-1"); + + assertThat(result.palletId()).isEqualTo(2092040891261427713L); + server.verify(); + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index e887bc8bf..380254e5d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -154,9 +154,9 @@ async function verifyTransfer(context, token, orders) { const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, - Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' + Boolean(transferNo) && created.data?.orderStatus === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), - `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) + `transferNo=${transferNo || 'missing'}, status=${created.data?.orderStatus || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!transferNo) return false const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, @@ -170,9 +170,9 @@ async function verifyTransfer(context, token, orders) { const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', `${TRANSFER}/${encodeURIComponent(transferNo)}`) context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, - id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' + id(detail.data?.transferNo) === transferNo && detail.data?.orderStatus === 'PENDING_REVIEW' && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, - `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + `status=${detail.data?.orderStatus || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo return true } diff --git a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs index 87bc9f03e..a28ce8ee6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-inbound-operations.mjs @@ -44,11 +44,11 @@ async function inboundAdminLifecycle(context) { const plan = await request(context, 'POST', '尾程入仓单转换为入仓计划', `${PLAN_BASE}/convert`, convertBody) const replay = await request(context, 'POST', '重复转换返回同一入仓计划', `${PLAN_BASE}/convert`, convertBody) context.assert(MODULE, '入仓计划转换天然幂等', null, - Boolean(plan?.plan?.planNo) && replay?.plan?.planNo === plan.plan.planNo, - `first=${plan?.plan?.planNo || 'missing'}, replay=${replay?.plan?.planNo || 'missing'}`, OPTIONS) - const planNo = plan?.plan?.planNo - const fbaLines = (plan?.lines || []).filter((line) => line?.destinationType === 'FBA' - && line?.fbaWarehouseCode && line?.destinationWarehouseId) + Boolean(plan?.planNo) && replay?.planNo === plan.planNo, + `first=${plan?.planNo || 'missing'}, replay=${replay?.planNo || 'missing'}`, OPTIONS) + const planNo = plan?.planNo + const fbaLines = (plan?.lines || []).filter((line) => line?.fbaWarehouseCode + && line?.destinationWarehouseId) context.assert(MODULE, 'FBA入仓计划包含可预约明细', null, Boolean(planNo) && fbaLines.length > 0, `planNo=${planNo || 'missing'}, fbaLines=${fbaLines.length}`, OPTIONS) From c218bfad6b6baa293cd7b425712b1c7969410ae6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:21:47 +0800 Subject: [PATCH 139/231] fix: expose warehouse transfer application status --- .../lastleg/transfer/dto/WarehouseTransferResponses.java | 1 + .../transfer/service/WarehouseTransferService.java | 1 + .../transfer/service/WarehouseTransferServiceTest.java | 5 ++++- .../phase4-overseas-client-services.mjs | 8 ++++---- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java index a081338e6..09cd8e495 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java @@ -16,6 +16,7 @@ public final class WarehouseTransferResponses { String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {} public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode, Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus, + String status, boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus, String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress, int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index ddafeed42..8995b02bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -225,6 +225,7 @@ public class WarehouseTransferService { transfer.getInboundCode(), transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), tailOrder == null ? ORDER_TYPE : tailOrder.getOrderType(), tailOrder == null ? (transfer.getStatus() == null ? PENDING_REVIEW : transfer.getStatus()) : tailOrder.getCurrentStatus(), + transfer.getStatus(), Boolean.TRUE.equals(transfer.getNeedRelabel()), transfer.getRelabelApplicationId(), transfer.getRelabelNo(), relabelStatus, transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index f4b449c62..df28e085b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -125,6 +125,7 @@ class WarehouseTransferServiceTest { verify(orders).updateById(tailOrder.getValue()); assertNotNull(tailOrder.getValue().getRelabelOrderNo()); assertEquals("PENDING_PROCESSING", tailOrder.getValue().getRelabelStatus()); + assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status()); assertEquals(WarehouseTransferService.PENDING_REVIEW, result.orderStatus()); } @@ -162,6 +163,7 @@ class WarehouseTransferServiceTest { assertEquals(WarehouseTransferService.APPROVED, transfer.getStatus()); assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, tailOrder.getCurrentStatus()); + assertEquals(WarehouseTransferService.APPROVED, result.status()); assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus()); verify(transfers).updateById(transfer); verify(orders).updateById(tailOrder); @@ -319,7 +321,8 @@ class WarehouseTransferServiceTest { WarehouseTransferOrder transfer = new WarehouseTransferOrder(); transfer.setId(21L); transfer.setCustomerId("TENANT-A"); transfer.setTransferNo("WTR-1"); transfer.setInboundOrderId(101L); transfer.setInboundCode("IN-1"); transfer.setLastlegOrderId(31L); - transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5); + transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); + transfer.setStatus(WarehouseTransferService.PENDING_REVIEW); transfer.setPackageCount(5); return transfer; } diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index 380254e5d..e887bc8bf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -154,9 +154,9 @@ async function verifyTransfer(context, token, orders) { const created = await merchant(context, token, 'POST', '客户创建仓储转运申请', TRANSFER, body) const transferNo = id(created.data?.transferNo) context.assert(MODULE, '仓储转运申请进入合法待审核状态且绑定本轮来源订单', created.response, - Boolean(transferNo) && created.data?.orderStatus === 'PENDING_REVIEW' + Boolean(transferNo) && created.data?.status === 'PENDING_REVIEW' && created.data?.sourceOrders?.some((item) => id(item?.lastlegOrderId) === id(order.row.id)), - `transferNo=${transferNo || 'missing'}, status=${created.data?.orderStatus || 'missing'}; ${trace(created.response)}`, OPTIONS) + `transferNo=${transferNo || 'missing'}, status=${created.data?.status || 'missing'}; ${trace(created.response)}`, OPTIONS) if (!transferNo) return false const replay = await merchant(context, token, 'POST', '客户仓储转运申请幂等重放', TRANSFER, body) context.assert(MODULE, '转运重放复用同一申请', replay.response, @@ -170,9 +170,9 @@ async function verifyTransfer(context, token, orders) { const detail = await merchant(context, token, 'GET', '客户查询自身仓储转运详情', `${TRANSFER}/${encodeURIComponent(transferNo)}`) context.assert(MODULE, '转运详情保持待审核且不产生出库履约事实', detail.response, - id(detail.data?.transferNo) === transferNo && detail.data?.orderStatus === 'PENDING_REVIEW' + id(detail.data?.transferNo) === transferNo && detail.data?.status === 'PENDING_REVIEW' && detail.data?.outboundAt == null && Array.isArray(detail.data?.allocations) && detail.data.allocations.length === 0, - `status=${detail.data?.orderStatus || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + `status=${detail.data?.status || 'missing'}, allocations=${detail.data?.allocations?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) context.createdResources.phase4OverseasClientServices.transferNo = transferNo return true } From 4839243f217342eb89e4cf859749e21d3a3e698c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 08:40:27 +0800 Subject: [PATCH 140/231] feat: add dropship fulfillment producer chain --- deploy/sql/头程数据库统一增量-MySQL8.sql | 44 +++++++++++++++++++ .../InternalInboundOrderController.java | 8 +++- .../firstleg/dto/InboundOrderSummary.java | 3 +- .../dto/OrderApprovedInboundCommand.java | 9 ++-- .../firstleg/entity/InboundOrderEntity.java | 1 + .../mapper/InboundSplitSettlementMapper.java | 4 +- .../mapper/LoadingCompletedEventMapper.java | 4 +- .../service/InboundOrderCreationService.java | 10 +++++ .../LoadingCompletedOutboxService.java | 22 +++++++--- .../resources/db/phase2-inbound-schema.sql | 21 +++++++++ .../main/resources/openapi/firstleg-v1.yaml | 5 ++- .../UnifiedInboundOpenApiContractTest.java | 7 ++- .../FulfillmentTypeSqlContractTest.java | 40 +++++++++++++++++ .../PdaSplitSettlementSqlContractTest.java | 1 + .../InboundOrderCreationServiceTest.java | 24 ++++++++++ .../LoadingCompletedOutboxServiceTest.java | 34 +++++++++++++- .../TaillegRealProducerAcceptanceTest.java | 16 ++++--- .../service/LoadingCompletedConsumer.java | 6 ++- .../service/LoadingCompletedConsumerTest.java | 2 +- .../order/client/FirstlegInboundClient.java | 2 +- .../com/baiyue/order/dto/OrderRequests.java | 26 +++++++++-- .../com/baiyue/order/entity/OrderEntity.java | 1 + .../service/OrderApplicationService.java | 11 ++++- .../service/OrderFulfillmentSplitService.java | 1 + order/src/main/resources/db/order.sql | 21 +++++++++ .../src/main/resources/openapi/order-v1.yaml | 8 +++- ...MerchantBatchDraftOpenApiContractTest.java | 3 ++ .../dto/OrderRequestsValidationTest.java | 19 ++++++++ .../OrderApplicationServiceStateTest.java | 27 +++++++++++- scripts/admin-test-env-acceptance.mjs | 16 ++++--- scripts/admin-test-env-modules/orders.mjs | 2 + .../phase3-loading-finalize.mjs | 9 +++- .../admin-test-env-modules/phase3-loading.mjs | 1 + ...-phase4-producer-fixture-contract.test.mjs | 31 +++++++++---- .../phase4-overseas-pda.mjs | 3 +- 35 files changed, 389 insertions(+), 53 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a6bd2d6c0..483eb69ce 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -583,6 +583,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '装运方式:FULL_CONTAINER整柜、LCL散货拼箱', customs_inspection TINYINT(1) NULL COMMENT '是否需要商检:0否、1是', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT 'Amazon目的仓编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -604,6 +605,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库可能仍保留草稿入仓码 NOT NULL 约束;必须先放宽再清理草稿入仓码。 SET @order_inbound_code_nullable_sql = IF( EXISTS( @@ -2531,6 +2552,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -2581,6 +2603,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + SET @inbound_global_code_index_exists := ( SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' @@ -4323,8 +4365,10 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_id'); CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); +CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); +CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_measurement_item', 'external_detail_no'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_channel_catalog'); CALL unified_assert_table('baiyue_firstleg', 'fl_price_origin_alias'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 651269bd1..4e299343f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -59,6 +59,9 @@ public class InternalInboundOrderController { || command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank() || command.inboundCode() == null || command.inboundCode().isBlank() || command.inboundCode().length() > 40 + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType())) || command.totalPackageCount() != null && command.totalPackageCount() < 1) { throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整"); } @@ -142,7 +145,10 @@ public class InternalInboundOrderController { || command.customerName() == null || command.customerName().isBlank() || command.warehouseId() == null || command.warehouseId() <= 0 || command.warehouseName() == null || command.warehouseName().isBlank() - || command.businessType() == null || command.businessType().isBlank()) { + || command.businessType() == null || command.businessType().isBlank() + || command.fulfillmentType() == null + || (!"STANDARD".equals(command.fulfillmentType()) + && !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) { throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index f5a98f09a..e9071df30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -17,7 +17,7 @@ public record InboundOrderSummary( Long customerId, String customerName, Long companyId, String companyName, Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName, - String businessType, String channelCode, String channelName, + String businessType, String fulfillmentType, String channelCode, String channelName, String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, Boolean holdFlag, String currentHoldReason, @@ -56,6 +56,7 @@ public record InboundOrderSummary( order.getCustomerId(), order.getCustomerName(), order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(), order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(), + order.getFulfillmentType(), order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java index 92665d4bd..ece4cea5e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderApprovedInboundCommand.java @@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand( String expressTrackingNo, Integer totalPackageCount, String pickupMethod, - LocalDateTime requestedPickupAt + LocalDateTime requestedPickupAt, + String fulfillmentType ) { public OrderApprovedInboundCommand( long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName, @@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, totalPackageCount, null, null); + expressTrackingNo, totalPackageCount, null, null, "STANDARD"); } /** 兼容尚未携带订单总箱数的历史事件。 */ public OrderApprovedInboundCommand( @@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } /** 兼容未携带报价包税维度的历史调用方。 */ @@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand( deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName, carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType, destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName, - expressTrackingNo, null, null, null); + expressTrackingNo, null, null, null, "STANDARD"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java index 43290b179..75f165e1b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundOrderEntity.java @@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity { private Long deliveryWarehouseId; private String deliveryWarehouseName; private String businessType; + private String fulfillmentType; private String channelCode; private String channelName; private Long carrierId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 647c9c080..ed0190ff2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper { INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status, order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status, expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name, - delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name, + delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name, tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id, destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count, actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg, @@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper { SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode}, CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, - company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, + company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason, #{packageCount},#{packageCount}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index c11e662f4..8675643a5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper { COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, o.order_id AS source_order_id, o.order_no AS source_order_no, COALESCE(root_order.order_id, o.order_id) AS root_order_id, - o.inbound_code, o.business_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, o.destination_name AS destination_warehouse_name, i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, @@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper { private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; private String fbaTrackingNo; private String shipmentNo; private String shippingMark; - private String businessType; private String destinationType; + private String businessType; private String fulfillmentType; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 193940b28..ac82be20b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -68,6 +68,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -160,6 +161,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName()) .set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt()) .set(InboundOrderEntity::getBusinessType, command.businessType()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getChannelCode, command.channelCode()) .set(InboundOrderEntity::getChannelName, command.channelName()) .set(InboundOrderEntity::getDestinationType, command.destinationType()) @@ -181,6 +183,7 @@ public class InboundOrderCreationService { .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType())) .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) .setSql("version = version + 1"); if (orders.update(null, update) != 1) { @@ -217,6 +220,7 @@ public class InboundOrderCreationService { inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); inbound.setBusinessType(command.businessType()); + inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType())); inbound.setChannelCode(command.channelCode()); inbound.setChannelName(command.channelName()); inbound.setCarrierId(command.carrierId()); @@ -241,6 +245,12 @@ public class InboundOrderCreationService { return inbound; } + private static String fulfillmentType(String value) { + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.BAD_REQUEST, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + private void addHistory(long inboundId, long tenantId, String from, String to, String action, LocalDateTime now) { InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java index f5879a22c..d2c706779 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -91,8 +91,9 @@ public class LoadingCompletedOutboxService { row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null, cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of()))) .toList(); - String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; - List pallets = dropship(first.getBusinessType()) + String fulfillmentType = fulfillmentType(sorted); + String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG"; + List pallets = dropship(fulfillmentType) ? palletSnapshots(sorted, palletRows) : List.of(); if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) { throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照"); @@ -110,7 +111,7 @@ public class LoadingCompletedOutboxService { /** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */ private Map> cartonLabelPools(List rows) { Map> result = new java.util.LinkedHashMap<>(); - Map> byInbound = rows.stream().filter(row -> !dropship(row.getBusinessType())) + Map> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType())) .filter(row -> "OVERSEAS".equals(destinationType(List.of(row)))) .collect(java.util.stream.Collectors.groupingBy( row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()), @@ -168,6 +169,18 @@ public class LoadingCompletedOutboxService { return values.iterator().next(); } + private String fulfillmentType(List rows) { + Set values = rows.stream().map(Row::getFulfillmentType) + .map(value -> value == null || value.isBlank() ? "STANDARD" : value) + .collect(java.util.stream.Collectors.toSet()); + if (values.size() != 1 + || (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) { + throw new BizException(ResultCode.CONFLICT, + "装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } + return values.iterator().next(); + } + private record LabelPoolKey(long accountId, long inboundId) { } private List palletSnapshots(List values, List source) { @@ -223,8 +236,7 @@ public class LoadingCompletedOutboxService { } private static boolean dropship(String value) { - return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value) - || value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP")); + return "ONE_CLICK_DROPSHIP".equals(value); } private record DetailSnapshot(Long detailId, String skuCode, String productName, diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index df2b3f30c..902aede82 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID', delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照', business_type VARCHAR(32) NOT NULL COMMENT '业务类型', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', channel_code VARCHAR(64) NULL COMMENT '渠道编码', channel_name VARCHAR(128) NULL COMMENT '渠道名称快照', carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID', @@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id) ) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照'; +SET @inbound_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type' +); +PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql; +EXECUTE inbound_fulfillment_type_stmt; +DEALLOCATE PREPARE inbound_fulfillment_type_stmt; + +UPDATE fl_inbound_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE fl_inbound_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。 SET @inbound_global_code_index_sql = IF( NOT EXISTS( diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index e893811e8..f38dfe72d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1122,6 +1122,7 @@ components: deliveryWarehouseId: {type: integer, format: int64, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} taxMode: {type: string, nullable: true} @@ -1466,7 +1467,7 @@ components: adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true} OrderApprovedInboundCommand: type: object - required: [tenantId, orderId, inboundCode, customerName, warehouseId] + required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType] properties: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1480,6 +1481,7 @@ components: deliveryWarehouseName: {type: string, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照} @@ -1810,6 +1812,7 @@ components: deliveryWarehouseId: {type: string, nullable: true} deliveryWarehouseName: {type: string, nullable: true} businessType: {type: string, nullable: true} + fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} containerMode: {type: string, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java index d18b7a6e3..8665093a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/UnifiedInboundOpenApiContractTest.java @@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest { assertThat(stringMap(inbound.get("properties"))).containsKeys( "id", "tenantId", "orderId", "inboundCode", "processStatus", "version", "customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount", - "businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", + "businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType", "holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm", "volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount", "inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo", "cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt", "createdByName", "createdAt", "updatedAt"); + + Map approval = stringMap(schemas.get("OrderApprovedInboundCommand")); + assertThat(stringList(approval.get("required"))).contains("fulfillmentType"); + Map fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType")); + assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP"); } @SuppressWarnings("unchecked") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java new file mode 100644 index 000000000..ca27c12fb --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FulfillmentTypeSqlContractTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.repository; + +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 FulfillmentTypeSqlContractTest { + @Test + void baselinesAndUnifiedMigrationPersistIndependentFulfillmentType() throws Exception { + String order = Files.readString(resolve("order/src/main/resources/db/order.sql"), StandardCharsets.UTF_8); + String inbound = Files.readString(resolve("firstleg/src/main/resources/db/phase2-inbound-schema.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + assertFulfillmentMigration(order, "baiyue_order.ord_order"); + assertFulfillmentMigration(inbound, "fl_inbound_order"); + assertFulfillmentMigration(unified, "baiyue_order.ord_order"); + assertFulfillmentMigration(unified, "fl_inbound_order"); + assertThat(unified) + .contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')") + .contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')"); + } + + private static void assertFulfillmentMigration(String sql, String table) { + assertThat(sql) + .contains("fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains("ADD COLUMN fulfillment_type VARCHAR(32) NULL") + .contains("SET fulfillment_type = 'STANDARD'") + .contains("MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'") + .contains(table); + } + + private static Path resolve(String path) { + Path direct = Path.of(path); + return Files.exists(direct) ? direct : Path.of("..").resolve(path).normalize(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 5d0c3b22a..636087f87 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -100,6 +100,7 @@ class PdaSplitSettlementSqlContractTest { assertThat(clone) .contains("#{childOrderId},#{childOrderNo}") .contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END") + .contains("business_type,fulfillment_type") .contains("actual_gross_weight_kg * #{packageCount}") .contains("actual_net_weight_kg * #{packageCount}"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 9f6c94123..a9583fa1b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest { assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); + assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD"); assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4); assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId()); assertThat(replayedId).isEqualTo(900L); } + @Test + void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() { + OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001"); + OrderApprovedInboundCommand command = new OrderApprovedInboundCommand( + base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(), + base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(), + base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(), + base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(), + base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), + base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), + base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), + "ONE_CLICK_DROPSHIP"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + service.createFromApprovedOrder(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOrderEntity.class); + verify(orders).insert(captor.capture()); + assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA"); + assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + } + @Test void shouldRejectReplayWithDifferentInboundCode() { InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 90ea941cb..461aca213 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -146,9 +146,9 @@ class LoadingCompletedOutboxServiceTest { } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { - Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP"); + Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP"); first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A"); - Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP"); + Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP"); second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B"); when(snapshots.rows(90L)).thenReturn(List.of(first, second)); when(snapshots.palletRows(90L)).thenReturn(List.of( @@ -168,6 +168,35 @@ class LoadingCompletedOutboxServiceTest { assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2); } + @Test + void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception { + Row value = row(11, 100, 101); + value.setBusinessType("ONE_CLICK_DROPSHIP"); + value.setFulfillmentType("STANDARD"); + when(snapshots.rows(90L)).thenReturn(List.of(value)); + + service.publish(90L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox).insert(captor.capture()); + EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class); + assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("FIRST_LEG"); + assertThat(event.payload().at("/orders/0/pallets")).isEmpty(); + } + + @Test + void rejectsMixedFulfillmentTypesWithinOneInbound() { + Row first = row(11, 100, 101); + Row second = row(11, 100, 102); + second.setFulfillmentType("ONE_CLICK_DROPSHIP"); + when(snapshots.rows(90L)).thenReturn(List.of(first, second)); + + assertThatThrownBy(() -> service.publish(90L)) + .hasMessageContaining("履约类型必须唯一"); + verify(outbox, org.mockito.Mockito.never()).insert( + org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class)); + } + private Row row(long account,long inbound,long detail) { Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); @@ -176,6 +205,7 @@ class LoadingCompletedOutboxServiceTest { r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); r.setDestinationType("OVERSEAS"); + r.setFulfillmentType("STANDARD"); r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java index df13829c5..e593f1aa2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java @@ -58,8 +58,8 @@ class TaillegRealProducerAcceptanceTest { BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ"); jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," + "updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING); - seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "LOADED", "ONT8"); - seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); + seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8"); + seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "FBA_SEA", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9"); seedItem(990022321L, FIRST_INBOUND, 990022421L, 990022221L, MARKER + "-FIRST-IN", "SKU-REAL-A", 3, "ONT8"); seedItem(990022322L, DROPSHIP_INBOUND, 990022422L, 990022222L, MARKER + "-DROP-IN", "SKU-REAL-D", 2, "LAX9"); for (int i = 1; i <= 3; i++) seedLabel(990022540L + i, FIRST_INBOUND, 990022221L, 990022421L, @@ -86,13 +86,15 @@ class TaillegRealProducerAcceptanceTest { id, 990022901L, planItemId, inboundId, detailId, packages, packages); } - private void seedInbound(long id, long orderId, String code, String type, String status, String destination) { + private void seedInbound(long id, long orderId, String code, String businessType, String fulfillmentType, + String status, String destination) { jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," + - "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_type,destination_warehouse_id," + + "order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,fulfillment_type,destination_type,destination_warehouse_id," + "destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " + - "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,'FBA',9001,?,?,?,0.4000,1,1)", - id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type, - destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); + "VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,?,'FBA',9001,?,?,?,0.4000,1,1)", + id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", businessType, + fulfillmentType, destination, "FBA " + destination, + fulfillmentType.equals("ONE_CLICK_DROPSHIP") ? 2 : 3); } private void seedItem(long id, long inboundId, long detailId, long orderId, String inboundCode, 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 c0709152b..bbb89c9ce 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 @@ -210,9 +210,11 @@ public class LoadingCompletedConsumer { LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId()); history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history); - if ("FIRST_LEG".equals(orderType)) { + if ("FIRST_LEG".equals(orderType) || "ONE_CLICK_DROPSHIP".equals(orderType)) { InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId()); - registerInTransit(event, payload, source, order, plan.planNo()); + if ("FIRST_LEG".equals(orderType)) { + registerInTransit(event, payload, source, order, plan.planNo()); + } } if ("ONE_CLICK_DROPSHIP".equals(orderType)) { long warehouseId; 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 dc66ad190..2646b549d 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 @@ -14,7 +14,7 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inboundPlans).convert(eq("100"),anyLong()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index de4b011fb..e61647e36 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -45,7 +45,7 @@ public interface FirstlegInboundClient { Boolean customsInspection, String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName, String publicRemark, String expressCompanyName, String expressTrackingNo, Integer totalPackageCount, - String pickupMethod, LocalDateTime requestedPickupAt + String pickupMethod, LocalDateTime requestedPickupAt, String fulfillmentType ) { } } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 28db86533..6786d6578 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -73,8 +73,28 @@ public final class OrderRequests { @Size(max = 64) String shipmentId, @Size(max = 64) String referenceId, @Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40) - @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode + @Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode, + @Schema(description = "Fulfillment type; omitted means STANDARD / 履约类型;省略时为普通头程", allowableValues = {"STANDARD", "ONE_CLICK_DROPSHIP"}, defaultValue = "STANDARD") + @Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType ) { + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, + String destinationCountryCode, Long destinationWarehouseId, + String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, + Party sender, Party receiver, List cargoes, String pickupMethod, + LocalDateTime requestedPickupAt, Long receivingWarehouseId, + String receivingWarehouseName, String channelCode, String channelName, + Long carrierId, String carrierName, String taxMode, String pricingTaxMode, + String containerMode, Boolean customsInspection, String destinationType, + String amazonWarehouseCode, String shipmentId, String referenceId, + String inboundCode) { + this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode, + destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark, + sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, + receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, + pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, + shipmentId, referenceId, inboundCode, null); + } + public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, String destinationCountryCode, Long destinationWarehouseId, String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark, @@ -89,7 +109,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null); + shipmentId, referenceId, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -106,7 +126,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null); + shipmentId, referenceId, null, null); } @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index 8a1e97d44..0c8fceec5 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -47,6 +47,7 @@ public class OrderEntity extends BaseEntity { private String containerMode; private Boolean customsInspection; private String destinationType; + private String fulfillmentType; private String amazonWarehouseCode; private String shipmentId; private String referenceId; diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 832ad9e86..9f2f9f841 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -462,6 +462,7 @@ public class OrderApplicationService { order.setPricingTaxMode(request.pricingTaxMode()); order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection()); order.setDestinationType(request.destinationType()); + order.setFulfillmentType(fulfillmentType(request.fulfillmentType())); order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); @@ -486,6 +487,13 @@ public class OrderApplicationService { } order.setInboundCode(normalized); } + + private static String fulfillmentType(String value) { + if (value == null) return "STANDARD"; + if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value; + throw new BizException(ResultCode.VALIDATION_FAILED, + "履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP"); + } private boolean legacyManualMode() { return inboundCodeModeProvider.legacyManual(); } @@ -599,7 +607,8 @@ public class OrderApplicationService { order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount, - order.getPickupMethod(), order.getRequestedPickupAt()); + order.getPickupMethod(), order.getRequestedPickupAt(), + order.getFulfillmentType() == null ? "STANDARD" : order.getFulfillmentType()); } private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java index 524868b10..e5fffe608 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -141,6 +141,7 @@ public class OrderFulfillmentSplitService { child.setDestinationWarehouseName(root.getDestinationWarehouseName()); child.setReceivingWarehouseId(root.getReceivingWarehouseId()); child.setReceivingWarehouseName(root.getReceivingWarehouseName()); child.setChannelCode(root.getChannelCode()); child.setChannelName(root.getChannelName()); child.setDestinationType(root.getDestinationType()); + child.setFulfillmentType(root.getFulfillmentType()); child.setInboundCode(remainderInboundCode(root, sequence)); child.setCreatedBy(operatorId); child.setUpdatedBy(operatorId); if (orders.insert(child) != 1) throw new BizException(ResultCode.CONFLICT, "余量子订单创建失败"); diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index d61276e08..27d732b9a 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( container_mode VARCHAR(24) NULL COMMENT '整柜或散货', customs_inspection TINYINT(1) NULL COMMENT '是否商检', destination_type VARCHAR(24) NULL COMMENT '目的仓类型:FBA亚马逊仓、OVERSEAS海外仓、PRIVATE私人地址', + fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码', shipment_id VARCHAR(64) NULL COMMENT '订单级Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '订单级Amazon Reference ID', @@ -61,6 +62,26 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +SET @order_fulfillment_type_sql = IF( + EXISTS( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order' + AND column_name = 'fulfillment_type' + ), + 'SELECT 1', + 'ALTER TABLE baiyue_order.ord_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER destination_type' +); +PREPARE order_fulfillment_type_stmt FROM @order_fulfillment_type_sql; +EXECUTE order_fulfillment_type_stmt; +DEALLOCATE PREPARE order_fulfillment_type_stmt; + +UPDATE baiyue_order.ord_order + SET fulfillment_type = 'STANDARD' + WHERE fulfillment_type IS NULL OR fulfillment_type = ''; +ALTER TABLE baiyue_order.ord_order + MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' + COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'; + -- 既有开发库必须先确认不存在跨客户重复码;若存在则在此处失败,不自动改写业务数据。 SET @order_global_inbound_index_sql = IF( NOT EXISTS( diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 9891be3ce..b015fdb8b 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -646,6 +646,11 @@ components: type: object required: [pricingTaxMode, cargoes] properties: + fulfillmentType: + type: string + enum: [STANDARD, ONE_CLICK_DROPSHIP] + default: STANDARD + description: 独立履约类型;省略为普通头程,不复用业务/计费 businessType inboundCode: type: string maxLength: 40 @@ -657,7 +662,8 @@ components: minItems: 1 items: {$ref: '#/components/schemas/FirstLegOrderCargo'} description: >- - 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode + 订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType + 省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode 是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID; 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。 InboundCodePolicy: diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java index 7329bc794..e685ef60d 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantBatchDraftOpenApiContractTest.java @@ -37,6 +37,9 @@ class MerchantBatchDraftOpenApiContractTest { .contains("merchant:order:create") .contains("FirstLegBatchDraftResponse") .contains("FirstLegBatchDraftResult") + .contains("fulfillmentType:") + .contains("enum: [STANDARD, ONE_CLICK_DROPSHIP]") + .contains("default: STANDARD") .contains("inboundCode:") .contains("errorCode:") .contains("errorMessage:"); diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index e269eacce..f58d48fe2 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -54,6 +54,25 @@ class OrderRequestsValidationTest { assertThat(validator.validate(draft)).isEmpty(); } + @Test + void fulfillmentTypeIsOptionalButRejectsPricingBusinessValues() { + var standard = request(null, null, "TAX_INCLUDED"); + assertThat(validator.validate(standard)).isEmpty(); + + var invalid = new OrderRequests.SaveRequest( + standard.customerId(), standard.customerName(), standard.transportMode(), standard.tradeTerm(), + standard.destinationCountryCode(), standard.destinationWarehouseId(), standard.destinationWarehouseName(), + standard.expectedShipDate(), standard.customerRemark(), standard.sender(), standard.receiver(), + standard.cargoes(), standard.pickupMethod(), standard.requestedPickupAt(), standard.receivingWarehouseId(), + standard.receivingWarehouseName(), standard.channelCode(), standard.channelName(), standard.carrierId(), + standard.carrierName(), standard.taxMode(), standard.pricingTaxMode(), standard.containerMode(), + standard.customsInspection(), standard.destinationType(), standard.amazonWarehouseCode(), + standard.shipmentId(), standard.referenceId(), standard.inboundCode(), "FBA_SEA"); + + assertThat(validator.validate(invalid)) + .anyMatch(violation -> violation.getPropertyPath().toString().equals("fulfillmentType")); + } + @Test void validatesTheManuallyEnteredInboundCodeForSingleAndBatchCallers() { var source = request(null, null, "TAX_INCLUDED"); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 636f7649a..be1b0dce3 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -60,7 +60,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && order.getInboundCode() == null)); + && order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType()))); } @Test @@ -372,6 +372,31 @@ class OrderApplicationServiceStateTest { assertThat(command.taxMode()).isEqualTo("BUY_DECLARATION"); assertThat(command.pricingTaxMode()).isEqualTo("TAX_EXCLUDED"); assertThat(command.totalPackageCount()).isEqualTo(4); + assertThat(command.fulfillmentType()).isEqualTo("STANDARD"); + } + + @Test + void explicitDropshipFulfillmentTypeIsPersistedAndSentIndependentlyFromPricingBusinessType() { + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + OrderRequests.SaveRequest source = request(); + OrderRequests.SaveRequest dropship = new OrderRequests.SaveRequest( + source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(), + source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(), + source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), source.cargoes(), + source.pickupMethod(), source.requestedPickupAt(), source.receivingWarehouseId(), + source.receivingWarehouseName(), source.channelCode(), source.channelName(), source.carrierId(), + source.carrierName(), source.taxMode(), source.pricingTaxMode(), source.containerMode(), + source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), + source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); + when(masterData.validate(10001L, dropship)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .createDraft(10001L, 2L, dropship, "dropship-fulfillment"); + + verify(repository).insertOrder(argThat(order -> "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType()) + && "OVERSEAS".equals(order.getDestinationType()))); } @Test diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index d230f0a72..8f0951ff7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -921,10 +921,10 @@ export class AcceptanceRun { } } - async runPhase4ProducerVariant(label, destinationType) { + async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` - const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] + const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], @@ -935,6 +935,7 @@ export class AcceptanceRun { let restoreFixture = null this.marker = variantMarker this.context.orderDestinationType = destinationType + this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker try { restoreFixture = await this.preparePhase3LoadingFixture() @@ -958,19 +959,22 @@ export class AcceptanceRun { async ensurePhase4PdaProducerMatrix() { const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} - if (Number(readiness().fbaContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA') + if (Number(readiness().dropshipContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP') + } + if (Number(readiness().fbaContainerCount || 0) < 2) { + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA') } if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') } - if (Number(readiness().distinctContainerCount || 0) < 3) { + if (Number(readiness().distinctContainerCount || 0) < 4) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA') } const matrix = this.createdResources.phase4InboundProducerFixtures this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, - `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index ebce176a2..186aae83b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -250,6 +250,7 @@ function buildPayload(context, profile, master, productImageFileId) { const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` return { + fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), customerId: master.partner?.id ?? null, customerName: master.partner?.partnerName ?? companyName, transportMode: 'SEA', tradeTerm: 'FOB', @@ -397,6 +398,7 @@ export async function run(context) { if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止') context.createdResources.orders = { marker: context.marker, tenantId, orderId, productImageFileId, + fulfillmentType: payload.fulfillmentType, boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), destinationType: master.destinationType, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index aa7bc88c8..345454daa 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -35,6 +35,7 @@ export function collectPhase4InboundProducerFixture(previous, producer) { destinationType, containers.filter((item) => item.destinationType === destinationType), ])) + const dropshipContainers = containers.filter((item) => item.fulfillmentType === 'ONE_CLICK_DROPSHIP') const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size return { marker: producer.marker, @@ -43,12 +44,15 @@ export function collectPhase4InboundProducerFixture(previous, producer) { readiness: { fbaContainerCount: byDestinationType.FBA.length, overseasContainerCount: byDestinationType.OVERSEAS.length, + dropshipContainerCount: dropshipContainers.length, distinctContainerCount, - readyForPhase4Pda: byDestinationType.FBA.length >= 1 + readyForPhase4Pda: byDestinationType.FBA.length >= 2 && byDestinationType.OVERSEAS.length >= 1 - && distinctContainerCount >= 3, + && dropshipContainers.length >= 1 + && distinctContainerCount >= 4, }, requiredEntryOrchestration: [ + 'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA', 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', @@ -367,6 +371,7 @@ export async function run(context) { sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], + fulfillmentType: fixture.fulfillmentType || 'STANDARD', destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), destinationWarehouseCode: fixture.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 38c2da961..f1cf90df1 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1215,6 +1215,7 @@ export async function run(context) { cartonMark: source?.boxCode || source?.cartonMark || null, cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) .map((value) => String(value || '').trim()).filter(Boolean))], + fulfillmentType: source?.fulfillmentType || 'STANDARD', destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 25c535b2a..9a7234e26 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -17,25 +17,32 @@ test('order fixture defaults to FBA and accepts only the explicit OVERSEAS varia const orders = read('orders.mjs') assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) + assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/) assert.match(orders, /destinationType,\s*\n\s*\}\)/) assert.doesNotMatch(orders, /const fbaResponse =/) }) -test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => { +test('producer collector requires dropship FBA, standard FBA, OVERSEAS and a fourth distinct container', () => { const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' } - const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' } + const dropship = { ...common, eventKey: 'DROP-EVENT', destinationType: 'FBA', + fulfillmentType: 'ONE_CLICK_DROPSHIP', containerNo: 'DROP-001' } + const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', + fulfillmentType: 'STANDARD', containerNo: 'FBA-001' } const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' } const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' } - const one = collectPhase4InboundProducerFixture(null, fba) - const two = collectPhase4InboundProducerFixture(one, overseasPrimary) - const ready = collectPhase4InboundProducerFixture(two, secondary) + const one = collectPhase4InboundProducerFixture(null, dropship) + const two = collectPhase4InboundProducerFixture(one, fba) + const three = collectPhase4InboundProducerFixture(two, overseasPrimary) + const ready = collectPhase4InboundProducerFixture(three, secondary) assert.equal(one.readiness.readyForPhase4Pda, false) assert.equal(two.readiness.readyForPhase4Pda, false) + assert.equal(three.readiness.readyForPhase4Pda, false) assert.equal(ready.readiness.readyForPhase4Pda, true) - assert.equal(ready.readiness.fbaContainerCount, 2) + assert.equal(ready.readiness.fbaContainerCount, 3) assert.equal(ready.readiness.overseasContainerCount, 1) - assert.equal(ready.readiness.distinctContainerCount, 3) - assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2') + assert.equal(ready.readiness.dropshipContainerCount, 1) + assert.equal(ready.readiness.distinctContainerCount, 4) + assert.equal(ready.byDestinationType.FBA[2].eventKey, 'FBA-EVENT-2') }) test('same event key is idempotently replaced and another marker starts a new matrix', () => { @@ -49,6 +56,7 @@ test('same event key is idempotently replaced and another marker starts a new ma assert.equal(nextRun.containers.length, 1) assert.equal(nextRun.marker, 'RUN-2') assert.deepEqual(nextRun.requiredEntryOrchestration, [ + 'DROPSHIP-FBA: public order fulfillmentType -> inbound-measurement -> phase3-loading -> phase3-loading-finalize -> overseas PDA', 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', @@ -61,7 +69,14 @@ test('measurement and loading pass destination identity into the LOADED producer const finalize = read('phase3-loading-finalize.mjs') assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) + assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/) assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) + assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/) assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) + const entry = read('../admin-test-env-acceptance.mjs') + assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) + assert.match(entry, /orderFulfillmentType/) + const pda = read('phase4-overseas-pda.mjs') + assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d24debae7..6aae9e42f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -413,7 +413,8 @@ export async function run(context) { return } const matrix = context.createdResources?.phase4InboundProducerFixtures - const producers = [matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] + const dropship = matrix?.containers?.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { From 29b2f080d81a0ad8bf01c76bd944497afdac571f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:16:00 +0800 Subject: [PATCH 141/231] test: preserve dropship producer fixture type --- scripts/admin-test-env-acceptance.mjs | 2 ++ .../phase3-phase4-producer-fixture-contract.test.mjs | 1 + 2 files changed, 3 insertions(+) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 8f0951ff7..7425a5ad7 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -880,6 +880,7 @@ export class AcceptanceRun { orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, boxCode: fixture.boxCode, + fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -899,6 +900,7 @@ export class AcceptanceRun { orderId: incrementalSource.orderId, inboundId: incrementalSource.inboundId, boxCode: incrementalSource.boxCode, + fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD', } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 9a7234e26..5c07ad16e 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -77,6 +77,7 @@ test('measurement and loading pass destination identity into the LOADED producer const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) assert.match(entry, /orderFulfillmentType/) + assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) const pda = read('phase4-overseas-pda.mjs') assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) }) From df4b7a1f6e382f8f7240a744af589aa4c965f817 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:23:28 +0800 Subject: [PATCH 142/231] test: carry frozen sku receipts into pda acceptance --- scripts/admin-test-env-acceptance.mjs | 6 +++++ scripts/admin-test-env-modules/orders.mjs | 4 +++ .../phase3-loading-finalize.mjs | 2 ++ .../admin-test-env-modules/phase3-loading.mjs | 4 +++ ...-phase4-producer-fixture-contract.test.mjs | 9 +++++++ .../phase4-overseas-pda.mjs | 25 ++++++++++++++----- 6 files changed, 44 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7425a5ad7..753f2b0db 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -881,6 +881,8 @@ export class AcceptanceRun { inboundId: fixture.inboundId, boxCode: fixture.boxCode, fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', + skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [], + skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [], customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, @@ -901,6 +903,10 @@ export class AcceptanceRun { inboundId: incrementalSource.inboundId, boxCode: incrementalSource.boxCode, fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD', + skuCodes: Array.isArray(this.createdResources.orders?.skuCodes) + ? this.createdResources.orders.skuCodes : [], + skuLines: Array.isArray(this.createdResources.orders?.skuLines) + ? this.createdResources.orders.skuLines : [], } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 186aae83b..3e7b83c41 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -399,6 +399,10 @@ export async function run(context) { context.createdResources.orders = { marker: context.marker, tenantId, orderId, productImageFileId, fulfillmentType: payload.fulfillmentType, + skuCodes: payload.cargoes.map((cargo) => cargo.skuNo), + skuLines: payload.cargoes.map((cargo) => ({ + skuCode: cargo.skuNo, packageCount: cargo.packageCount, + })), boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), destinationType: master.destinationType, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 345454daa..c77b313ae 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -371,6 +371,8 @@ export async function run(context) { sourceInboundId: inboundId, cartonMark: fixture.cartonMark || null, cartonMarks: Array.isArray(fixture.cartonMarks) ? fixture.cartonMarks : [], + skuCodes: Array.isArray(fixture.skuCodes) ? fixture.skuCodes : [], + skuLines: Array.isArray(fixture.skuLines) ? fixture.skuLines : [], fulfillmentType: fixture.fulfillmentType || 'STANDARD', destinationType: fixture.destinationType || null, destinationWarehouseId: id(fixture.destinationWarehouseId), diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f1cf90df1..bd977c3e0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1216,6 +1216,10 @@ export async function run(context) { cartonMarks: [...new Set((stagedTask?.items || []).flatMap((item) => item?.boxCodes || []) .map((value) => String(value || '').trim()).filter(Boolean))], fulfillmentType: source?.fulfillmentType || 'STANDARD', + skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []), + ...(Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])], + skuLines: [...(Array.isArray(source?.skuLines) ? source.skuLines : []), + ...(Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])], destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 5c07ad16e..6574c4e2c 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -18,6 +18,8 @@ test('order fixture defaults to FBA and accepts only the explicit OVERSEAS varia assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) assert.match(orders, /destinationType: master\.destinationType/) assert.match(orders, /fulfillmentType: String\(context\.context\?\.orderFulfillmentType \|\| 'STANDARD'\)/) + assert.match(orders, /skuCodes: payload\.cargoes\.map\(\(cargo\) => cargo\.skuNo\)/) + assert.match(orders, /skuLines: payload\.cargoes\.map\(\(cargo\) => \(\{/) assert.match(orders, /destinationType,\s*\n\s*\}\)/) assert.doesNotMatch(orders, /const fbaResponse =/) }) @@ -70,14 +72,21 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) assert.match(loading, /fulfillmentType: source\?\.fulfillmentType \|\| 'STANDARD'/) + assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuCodes\)/) + assert.match(loading, /Array\.isArray\(incrementalFixture\?\.skuLines\)/) assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) assert.match(finalize, /fulfillmentType: fixture\.fulfillmentType \|\| 'STANDARD'/) + assert.match(finalize, /skuCodes: Array\.isArray\(fixture\.skuCodes\) \? fixture\.skuCodes : \[\]/) + assert.match(finalize, /skuLines: Array\.isArray\(fixture\.skuLines\) \? fixture\.skuLines : \[\]/) assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) + assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) + assert.match(entry, /skuLines: Array\.isArray\(fixtureOrder\.skuLines\) \? fixtureOrder\.skuLines : \[\]/) const pda = read('phase4-overseas-pda.mjs') assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) + assert.match(pda, /skuFixtures\(context, producer\)/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 6aae9e42f..011a96b26 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -175,31 +175,42 @@ async function awaitProducerCandidates(context, token, producers) { return ready } -function skuFixtures(context) { +function skuFixtures(context, producer) { + const frozenLines = Array.isArray(producer?.skuLines) + ? producer.skuLines + .map((line) => ({ skuCode: id(line?.skuCode), packageCount: Number(line?.packageCount) })) + .filter((line) => nonBlank(line.skuCode) && Number.isInteger(line.packageCount) && line.packageCount > 0) + : [] + if (frozenLines.length) return frozenLines + const frozen = Array.isArray(producer?.skuCodes) + ? producer.skuCodes.map(id).filter(nonBlank) + : [] + if (frozen.length) return [...new Set(frozen)].map((skuCode) => ({ skuCode, packageCount: 1 })) const boxes = context.createdResources?.orders?.boxTypes const orchestration = context.createdResources?.orchestrationFixtures || {} const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) return Array.isArray(boxes) ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + .map((skuCode) => ({ skuCode, packageCount: 1 })) : [] } -async function receiveFba(context, token, inbound, pallet) { - const skus = skuFixtures(context) +async function receiveFba(context, token, inbound, pallet, producer) { + const skus = skuFixtures(context, producer) if (!skus.length) { blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') return false } let accepted = 0 - for (const [index, skuCode] of skus.entries()) { + for (const [index, sku] of skus.entries()) { + const { skuCode, packageCount } = sku const resolvedResponse = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) const resolvable = success(resolvedResponse) || notFound(resolvedResponse) context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue - const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1 const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) @@ -350,7 +361,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) - for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) + for (const inbound of fbaInbounds) { + await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode), preferredProducer) + } } else if (preferredProducer?.destinationType !== 'OVERSEAS') { blockerGap(context, 'FBA SKU按箱数链', '当前FBA柜没有带真实托盘的 FBA 入仓码') } From 46dd0b54eb146b8120052666bd9624b2a1a696bf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:27:45 +0800 Subject: [PATCH 143/231] test: isolate pda receipt idempotency keys --- .../phase4-overseas-pda-contract.test.mjs | 2 ++ scripts/admin-test-env-modules/phase4-overseas-pda.mjs | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 45e5a374e..87c2addb0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -34,6 +34,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /lastleg:pda:operate/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) + assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) + assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) assert.match(source, /安全扫描返回A0400/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 011a96b26..9a57ec520 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -212,7 +212,7 @@ async function receiveFba(context, token, inbound, pallet, producer) { const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, - packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } + packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) if (!success(first.response)) return false accepted += Number(first.data?.acceptedPackages || 0) @@ -222,7 +222,7 @@ async function receiveFba(context, token, inbound, pallet, producer) { await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { ...body, packageCount: packageCount + 1 }, conflict) await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', - { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict) + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, `fba-over-${index}`) }, conflict) } if (accepted <= 0) { blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) @@ -250,7 +250,7 @@ async function receiveOverseas(context, token, inbounds, pallets, producerCarton return false } const body = { cartonMark, palletCode: pallet.palletCode, - idempotencyKey: key(context, 'overseas-carton', cartonMark) } + idempotencyKey: key(context, `overseas-carton-${index}`) } const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) if (!success(first.response)) return false @@ -263,7 +263,7 @@ async function receiveOverseas(context, token, inbounds, pallets, producerCarton { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) await expected(context, token, 'POST', `OVERSEAS重复箱新键被拒绝${index + 1}`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', - { ...body, idempotencyKey: key(context, 'overseas-duplicate', cartonMark) }, conflict) + { ...body, idempotencyKey: key(context, `overseas-duplicate-${index}`) }, conflict) } return true } From b2111213650e493a851af56bbdd3e7281239429c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:50:02 +0800 Subject: [PATCH 144/231] test: expose acceptance coverage metrics --- scripts/admin-test-env-acceptance.mjs | 58 ++++++++++++++-- ...-test-env-report-summary-contract.test.mjs | 68 +++++++++++++++++++ 2 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 scripts/admin-test-env-report-summary-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 753f2b0db..51ba55c58 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -281,6 +281,56 @@ function derived(length, width, height, edge) { return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) } } +export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = []) { + const failed = steps.filter((step) => !step.passed).length + const executedModules = modules.filter((module) => !module.skipped) + const uniqueModuleNames = new Set(executedModules.map((module) => module.name).filter(Boolean)) + const uniqueRequestKeys = new Set() + const uniqueStepNameSet = new Set() + + for (const step of steps) { + if (step.name) uniqueStepNameSet.add(step.name) + const method = step.request?.method + const path = step.request?.path + if (!method || !path) continue + uniqueRequestKeys.add(`${method}\u001f${path}\u001f${step.requestId || ''}`) + } + + const coverageGapStats = { + total: coverageGaps.length, + blocking: 0, + nonBlocking: 0, + byCategory: {}, + } + for (const gap of coverageGaps) { + const category = gap.category || 'uncategorized' + const blocking = gap.blocking === true + const categoryStats = coverageGapStats.byCategory[category] + || { total: 0, blocking: 0, nonBlocking: 0 } + categoryStats.total += 1 + if (blocking) { + coverageGapStats.blocking += 1 + categoryStats.blocking += 1 + } else { + coverageGapStats.nonBlocking += 1 + categoryStats.nonBlocking += 1 + } + coverageGapStats.byCategory[category] = categoryStats + } + + return { + total: steps.length, + passed: steps.length - failed, + failed, + moduleRuns: executedModules.length, + uniqueModules: uniqueModuleNames.size, + uniqueRequests: uniqueRequestKeys.size, + uniqueStepNames: uniqueStepNameSet.size, + skippedModules: modules.length - executedModules.length, + coverageGaps: coverageGapStats, + } +} + export class AcceptanceRun { constructor(options) { this.options = options @@ -1141,13 +1191,13 @@ export class AcceptanceRun { this.addStep('report-security', '报告敏感信息扫描', null, !sensitive, sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段') - const failed = this.steps.filter((step) => !step.passed).length + const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, gateway: this.options.gateway, overseasGateway: this.options.overseasGateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, - success: this.fatal === null && failed === 0, fatal: this.fatal, - summary: { total: this.steps.length, passed: this.steps.length - failed, failed }, + success: this.fatal === null && summary.failed === 0, fatal: this.fatal, + summary, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, @@ -1169,7 +1219,7 @@ async function main() { await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.log(`REPORT=${outputPath}`) - console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed}`) + console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed} metric=assertions moduleRuns=${report.summary.moduleRuns} uniqueModules=${report.summary.uniqueModules} uniqueRequests=${report.summary.uniqueRequests} skippedModules=${report.summary.skippedModules}`) process.exitCode = report.success ? 0 : 1 } diff --git a/scripts/admin-test-env-report-summary-contract.test.mjs b/scripts/admin-test-env-report-summary-contract.test.mjs new file mode 100644 index 000000000..1171aecce --- /dev/null +++ b/scripts/admin-test-env-report-summary-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' + +test('report summary separates assertion, module-run and unique-request metrics', () => { + const sharedResponse = { + method: 'GET', path: '/api/platform/v1/orders?page=1', requestId: 'request-1', + } + const steps = [ + { module: 'orders', name: '订单列表成功', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表数据结构正确', passed: true, + request: { method: sharedResponse.method, path: sharedResponse.path }, requestId: sharedResponse.requestId }, + { module: 'orders', name: '订单列表成功', passed: false, + request: { method: 'GET', path: '/api/platform/v1/orders?page=2' }, requestId: 'request-2' }, + { module: 'report-security', name: '报告敏感信息扫描', passed: true, request: null, requestId: null }, + ] + const modules = [ + { name: 'orders' }, + { name: 'orders' }, + { name: 'finance', skipped: 'owned by finance full chain' }, + ] + + const summary = buildAcceptanceSummary(steps, modules, []) + + assert.deepEqual(summary, { + total: 4, + passed: 3, + failed: 1, + moduleRuns: 2, + uniqueModules: 1, + uniqueRequests: 2, + uniqueStepNames: 3, + skippedModules: 1, + coverageGaps: { total: 0, blocking: 0, nonBlocking: 0, byCategory: {} }, + }) +}) + +test('request evidence without requestId is deduplicated by method and path', () => { + const summary = buildAcceptanceSummary([ + { name: 'CSV 状态', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV 表头', passed: true, request: { method: 'GET', path: '/api/export' }, requestId: null }, + { name: 'CSV POST', passed: true, request: { method: 'POST', path: '/api/export' }, requestId: null }, + ]) + + assert.equal(summary.uniqueRequests, 2) +}) + +test('coverage gaps are counted by category and blocking status without copying details', () => { + const summary = buildAcceptanceSummary([], [], [ + { category: 'device', blocking: false, reason: 'camera', secretEvidence: 'must-not-copy' }, + { category: 'device', blocking: true, reason: 'scanner' }, + { category: 'external-third-party', blocking: false, reason: 'captcha' }, + { blocking: true, reason: 'fixture' }, + ]) + + assert.deepEqual(summary.coverageGaps, { + total: 4, + blocking: 2, + nonBlocking: 2, + byCategory: { + device: { total: 2, blocking: 1, nonBlocking: 1 }, + 'external-third-party': { total: 1, blocking: 0, nonBlocking: 1 }, + uncategorized: { total: 1, blocking: 1, nonBlocking: 0 }, + }, + }) + assert.equal(JSON.stringify(summary).includes('must-not-copy'), false) +}) From b67217332aa565f213779b0e304fc2b7f6111beb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:52:49 +0800 Subject: [PATCH 145/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/baiyue/common/event/EventTypes.java | 2 + ...rstLegContainerShippingNodeRecordedV1.java | 41 +++++ ...egContainerShippingNodeRecordedV1Test.java | 43 +++++ .../BaiyueOpenApiAutoConfiguration.java | 8 + .../openapi/PdaOpenApiGroupContractTest.java | 17 ++ ...EventBridgeReliabilitySqlContractTest.java | 39 ++++ .../service/OverseasEventDispatcherTest.java | 54 ++++++ .../CarrierScheduleNodeOutboxService.java | 75 ++++++++ .../LoadingCompletedOutboxRepository.java | 11 +- .../service/LoadingCompletedOutboxRelay.java | 7 +- .../CarrierScheduleNodeOutboxServiceTest.java | 77 ++++++++ .../main/resources/application-overseas.yml | 1 + .../OverseasLastlegRouteContractTest.java | 11 ++ lastleg-service/AGENTS.md | 6 +- .../InboundPalletAssignmentController.java | 52 ------ .../pda/controller/InboundPdaController.java | 146 --------------- .../ReceivingContainerController.java | 50 ++--- .../pda/security/PdaWarehouseIdentity.java | 32 ++++ .../warehouse/dto/PdaWarehouseRequests.java | 15 ++ .../warehouse/dto/PdaWarehouseResponses.java | 23 +++ .../warehouse/entity/PdaOperationHistory.java | 26 +++ .../warehouse/mapper/PdaWarehouseMapper.java | 72 ++++++++ .../model/PdaInventoryProjection.java | 25 +++ .../PlatformUsTruckOrderController.java | 69 +++++++ .../trucking/dto/UsTruckOrderRequests.java | 29 ++- .../trucking/dto/UsTruckOrderResponses.java | 5 +- .../lastleg/trucking/entity/UsTruckOrder.java | 3 - .../entity/UsTruckOrderOperation.java | 22 +++ .../entity/UsTruckOrderStatusHistory.java | 2 + .../mapper/UsTruckOrderOperationMapper.java | 15 ++ .../trucking/service/UsTruckOrderService.java | 174 ++++++++++++++---- .../Phase4BrowserApiBoundaryTest.java | 18 +- ...InboundPalletAssignmentControllerTest.java | 42 ----- .../admin-test-env-modules/phase3-loading.mjs | 102 +++++++++- .../phase3-producer-chain-contract.test.mjs | 26 +++ .../PlatformWarehouseResourceController.java | 3 +- ...rehouseResourcePermissionContractTest.java | 9 +- 37 files changed, 1009 insertions(+), 343 deletions(-) create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java create mode 100644 common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 7c23de9c9..029056ada 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -10,6 +10,8 @@ public final class EventTypes { public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED = + "FirstLegContainerShippingNodeRecorded"; public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded"; public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; 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 new file mode 100644 index 000000000..9d1dbf684 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -0,0 +1,41 @@ +package com.baiyue.common.event; + +import java.time.Instant; + +/** Actual carrier milestone recorded by Firstleg for overseas receiving readiness. */ +public record FirstLegContainerShippingNodeRecordedV1( + String queryId, + String customerId, + String oceanBookingId, + String containerBookingId, + String containerNo, + String standardNode, + int nodeSequence, + Instant observedAt, + Instant actualAt, + String locationCode, + String locationName, + String providerCode, + String providerEventId, + String providerNodeCode, + String sourceReference, + String rawPayloadHash) { + + public FirstLegContainerShippingNodeRecordedV1 { + if (queryId == null || queryId.isBlank() || customerId == null || customerId.isBlank() + || oceanBookingId == null || oceanBookingId.isBlank() + || containerBookingId == null || containerBookingId.isBlank() + || containerNo == null || containerNo.isBlank()) { + throw new IllegalArgumentException("船节点交接业务标识不能为空"); + } + if (standardNode == null || standardNode.isBlank() || nodeSequence < 10 + || observedAt == null || actualAt == null) { + throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实"); + } + if (providerCode == null || providerCode.isBlank() || providerEventId == null + || providerEventId.isBlank() || providerNodeCode == null || providerNodeCode.isBlank() + || rawPayloadHash == null || !rawPayloadHash.matches("(?i)^[0-9a-f]{64}$")) { + 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 new file mode 100644 index 000000000..3f69c7f1f --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -0,0 +1,43 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FirstLegContainerShippingNodeRecordedV1Test { + @Test + void roundTripsLongBusinessIdsAndActualPickupReadyFact() throws Exception { + var payload = payload(); + var mapper = new ObjectMapper().findAndRegisterModules(); + + var restored = mapper.readValue(mapper.writeValueAsString(payload), + FirstLegContainerShippingNodeRecordedV1.class); + + assertThat(restored).isEqualTo(payload); + assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003"); + assertThat(restored.standardNode()).isEqualTo("PICKUP_READY"); + assertThat(restored.nodeSequence()).isEqualTo(80); + } + + @Test + void rejectsEstimatedOnlyOrIncompleteProviderFacts() { + assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( + "1", "2", "3", "4", "ZCSU7881970", "PICKUP_READY", 80, + 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("实际时间"); + } + + private static FirstLegContainerShippingNodeRecordedV1 payload() { + return new FirstLegContainerShippingNodeRecordedV1( + "2088000000000000001", "2088000000000000002", "2088000000000000004", + "2088000000000000003", "ZCSU7881970", "PICKUP_READY", 80, + Instant.parse("2026-08-25T01:02:00Z"), Instant.parse("2026-08-25T01:00:00Z"), + "USLAX", "Los Angeles", "ZIM", "evt-1", "AVAILABLE", + "ZIM:evt-1", "a".repeat(64)); + } +} diff --git a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java index 3a5a42502..de69a8909 100644 --- a/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java +++ b/common-openapi/src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java @@ -75,6 +75,14 @@ public class BaiyueOpenApiAutoConfiguration { .addOperationCustomizer(correlationHeaderCustomizer).build(); } + @Bean + GroupedOpenApi pdaApi(OperationCustomizer correlationHeaderCustomizer) { + return GroupedOpenApi.builder().group("pda") + .displayName("PDA APIs / PDA 作业接口") + .pathsToMatch("/api/pda/**") + .addOperationCustomizer(correlationHeaderCustomizer).build(); + } + private Parameter header(String name, String description, boolean required) { return new HeaderParameter().name(name).description(description).required(required) .schema(new io.swagger.v3.oas.models.media.StringSchema()); diff --git a/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java new file mode 100644 index 000000000..dcf28b7df --- /dev/null +++ b/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java @@ -0,0 +1,17 @@ +package com.baiyue.common.openapi; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOpenApiGroupContractTest { + @Test + void pdaGroupOnlyMatchesDeviceApis() { + BaiyueOpenApiAutoConfiguration configuration = new BaiyueOpenApiAutoConfiguration(); + var group = configuration.pdaApi(configuration.correlationHeaderCustomizer()); + + assertThat(group.getGroup()).isEqualTo("pda"); + assertThat(group.getPathsToMatch()).containsExactly("/api/pda/**"); + assertThat(group.getPathsToMatch()).noneMatch(path -> path.contains("internal")); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java new file mode 100644 index 000000000..0869ea824 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventBridgeReliabilitySqlContractTest.java @@ -0,0 +1,39 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeReliabilitySqlContractTest { + @Test + void schedulingAndLeaseSqlCoverRetryDeadAndExpiredLeaseTakeover() throws Exception { + String candidates = sql(BridgeInboxMapper.class.getMethod("dispatchCandidates", int.class), Select.class); + String claim = sql(BridgeInboxMapper.class.getMethod( + "claim", String.class, String.class, long.class), Update.class); + String failed = sql(BridgeInboxMapper.class.getMethod( + "markFailed", String.class, String.class, int.class, long.class, String.class), Update.class); + String processed = sql(BridgeInboxMapper.class.getMethod( + "markProcessed", String.class, String.class), Update.class); + + assertThat(candidates).contains("status IN ('RECEIVED','RETRY')", "next_retry_at <= CURRENT_TIMESTAMP(3)", + "status = 'PROCESSING'", "lease_until < CURRENT_TIMESTAMP(3)", "ORDER BY received_at"); + assertThat(claim).contains("status='PROCESSING'", "lease_owner=#{owner}", "lease_until=DATE_ADD", + "status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)"); + assertThat(failed).contains("retry_count=retry_count+1", "status=CASE", "THEN 'DEAD' ELSE 'RETRY'", + "next_retry_at=CASE", "THEN NULL", "last_error=#{error}", "lease_owner=#{owner}"); + assertThat(processed).contains("status='PROCESSED'", "next_retry_at=NULL", "last_error=NULL", + "status='PROCESSING'", "lease_owner=#{owner}"); + } + + private String sql(Method method, Class type) { + A annotation = method.getAnnotation(type); + String[] statements = annotation instanceof Select select ? select.value() : ((Update) annotation).value(); + return String.join(" ", Arrays.asList(statements)).replaceAll("\\s+", " "); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 67cb7b3d1..01468d95a 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.time.Duration; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -103,6 +104,59 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { + for (String target : List.of("OVERSEAS", "CN")) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient); + event.setSourceRegion("CN".equals(target) ? "OVERSEAS" : "CN"); + event.setTargetRegion(target); + event.setRetryCount(0); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1, 2); + doAnswer(invocation -> { + event.setRetryCount(1); + event.setStatus("RETRY"); + event.setLastError(invocation.getArgument(4)); + event.setNextRetryAt(java.time.LocalDateTime.now().plusSeconds( + ((Number) invocation.getArgument(3)).longValue())); + return 1; + }).when(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), anyString()); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenAnswer(invocation -> { + event.setStatus("PROCESSED"); + event.setLastError(null); + event.setNextRetryAt(null); + return 1; + }); + if ("CN".equals(target)) { + when(firstlegEventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-cn traceId=trace-cn")) + .thenReturn("200"); + } else { + when(eventClient.deliver(event)) + .thenThrow(new IllegalStateException("HTTP 503 code=S0503 requestId=req-overseas traceId=trace-overseas")) + .thenReturn("200"); + } + + dispatcher.dispatchOne("event-1"); + assertThat(event.getStatus()).isEqualTo("RETRY"); + assertThat(event.getNextRetryAt()).isNotNull(); + assertThat(event.getLastError()).contains("HTTP 503", "requestId=", "traceId="); + dispatcher.dispatchOne("event-1"); + + ArgumentCaptor attempts = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper, times(2)).updateById(attempts.capture()); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getAttemptNo) + .containsExactly(1, 2); + assertThat(attempts.getAllValues()).extracting(BridgeDeliveryAttempt::getStatus) + .containsExactly("RETRY", "DELIVERED"); + assertThat(attempts.getAllValues().get(0).getErrorMessage()) + .contains("HTTP 503", "requestId=", "traceId="); + assertThat(event.getStatus()).isEqualTo("PROCESSED"); + assertThat(event.getLastError()).isNull(); + } + } + private BridgeDeliveryAttempt updatedAttempt() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java new file mode 100644 index 000000000..5f949eece --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java @@ -0,0 +1,75 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +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.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity; +import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** Writes actual carrier node facts to the domestic transactional Outbox. */ +@Service +public class CarrierScheduleNodeOutboxService { + @Resource private InboundOutboxMapper outbox; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}") + private String scheduleZone = "Asia/Shanghai"; + + public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact, + String containerNo) { + if (fact.getActualAt() == null) return; + ZoneId zone = ZoneId.of(scheduleZone); + var payload = new FirstLegContainerShippingNodeRecordedV1( + string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()), + string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(), + fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(), + fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(), + fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash()); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion()); + String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId(); + EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1, + fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS, + string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()), + aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null, + payloadNode, payloadHash).requireValid(); + + InboundOutboxEntity row = new InboundOutboxEntity(); + row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId()); + row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId()); + row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1); + row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash); + row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now()); + try { outbox.insert(row); } catch (DuplicateKeyException ignored) { } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败"); + } + } + + private static String string(Long value) { return value == null ? null : String.valueOf(value); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index cb8935edd..89d6f427f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -13,11 +13,14 @@ import java.util.List; @Repository public class LoadingCompletedOutboxRepository { + private static final List CROSS_REGION_EVENT_TYPES = List.of( + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); @Resource private InboundOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.selectList(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) .or().le(InboundOutboxEntity::getNextRetryAt, now)) @@ -29,7 +32,7 @@ public class LoadingCompletedOutboxRepository { public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { return mapper.update(null, new LambdaUpdateWrapper() .eq(InboundOutboxEntity::getId, id) - .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) @@ -37,7 +40,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getLockedAt, now)) == 1; } public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() - .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) @@ -48,7 +51,7 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper() - .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES) .eq(InboundOutboxEntity::getPublishStatus,status); } private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); } private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index 30bf94202..c635fbe76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */ +/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; @@ -39,12 +39,13 @@ public class LoadingCompletedOutboxRelay { EventEnvelopeValidator.requireValid(event); var response=client.receive(internalToken,event); if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); + || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件"); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); - log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error); + log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + row.getEventType(), row.getEventKey(), error); } } } 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 new file mode 100644 index 000000000..27a76c2b4 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxServiceTest.java @@ -0,0 +1,77 @@ +package com.baiyue.firstleg.portschedule.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class CarrierScheduleNodeOutboxServiceTest { + private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService(); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "outbox", mapper); + ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai"); + } + + @Test + void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception { + CarrierScheduleQueryEntity query = query(); + CarrierScheduleNodeFactEntity fact = fact(); + + service.publish(query, fact, "ZCSU7881970"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(mapper).insert(captor.capture()); + InboundOutboxEntity row = captor.getValue(); + EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); + assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED); + assertThat(envelope.eventVersion()).isEqualTo(1); + assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003"); + assertThat(envelope.aggregateVersion()).isEqualTo(7); + assertThat(envelope.handoverVersion()).isEqualTo(8); + assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY"); + assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z"); + } + + @Test + void estimatedOnlyFactNeverEntersCrossRegionOutbox() { + CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null); + service.publish(query(), fact, "ZCSU7881970"); + verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any()); + } + + private static CarrierScheduleQueryEntity query() { + CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); + value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L); + value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L); + value.setVersion(7); return value; + } + + private static CarrierScheduleNodeFactEntity fact() { + CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity(); + value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80); + value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2)); + value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM"); + value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE"); + value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value; + } +} diff --git a/gateway-overseas/src/main/resources/application-overseas.yml b/gateway-overseas/src/main/resources/application-overseas.yml index f7204c15d..bdcda68d8 100644 --- a/gateway-overseas/src/main/resources/application-overseas.yml +++ b/gateway-overseas/src/main/resources/application-overseas.yml @@ -60,6 +60,7 @@ springdoc: urls: - { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant } - { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform } + - { name: lastleg-pda, url: /lastleg-service/v3/api-docs/pda } - { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant } - { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform } - { name: file-platform, url: /file-service/v3/api-docs/platform } diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java index e83bb3312..345210960 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java @@ -27,4 +27,15 @@ class OverseasLastlegRouteContractTest { .doesNotContain("StripPrefix", "RewritePath"); } } + + @Test + void swaggerPublishesAnIndependentLastlegPdaGroup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/application-overseas.yml")) { + assertThat(input).isNotNull(); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertThat(yaml).contains("name: lastleg-pda", "url: /lastleg-service/v3/api-docs/pda") + .contains("Path=/lastleg-service/v3/api-docs/**") + .doesNotContain("event-bridge-service/v3/api-docs/pda"); + } + } } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 99b6a8795..d2853f74d 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -22,9 +22,9 @@ | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API | -| PDA 卸柜照片文件上传与归属校验 | `InboundPdaController.java` 的 `/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | -| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 | +| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | +| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java deleted file mode 100644 index 387334dae..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentController.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.PathVariable; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - -@RestController -@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets") -public class InboundPalletAssignmentController { - @Resource private InboundPdaWorkflowService service; - - @PostMapping("/{planPalletId}/assign") - public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, @PathVariable long planPalletId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.Assign request) { - requireOperate(user); - service.assign(scope(user, customerId), planNo, planPalletId, request); - return ApiResponse.success(null); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } - - private void requireOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(InboundPdaController.OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, - InboundPdaController.OPERATE_PERMISSION + " permission is required"); - } - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java deleted file mode 100644 index 29056ca92..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ /dev/null @@ -1,146 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaResponses; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import jakarta.validation.Valid; -import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.multipart.MultipartFile; -import org.springframework.http.MediaType; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; - -@RestController -@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}") -public class InboundPdaController { - public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; - @Resource private InboundPdaWorkflowService service; - - @PostMapping("/push") - public ApiResponse push(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.Push request) { - requireOperate(user); - return ApiResponse.success(service.push(scope(user, customerId), planNo, request)); - } - - @GetMapping - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId) { - return ApiResponse.success(service.task(scope(user, customerId), planNo)); - } - - @PostMapping("/allocations/{allocationId}/count") - public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.PackageOperation request) { - requireOperate(user); - return ApiResponse.success(service.count(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/allocations/{allocationId}/pick") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.PackageOperation request) { - requireOperate(user); - return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/cartons/scan") - public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.ScanCarton request) { - requireOperate(user); - return ApiResponse.success(service.scanCarton(scope(user, customerId), planNo, request)); - } - - @PostMapping("/cartons/remove") - public ApiResponse removeCarton(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.RemoveCarton request) { - requireOperate(user); - return ApiResponse.success(service.removeCarton(scope(user, customerId), planNo, request)); - } - - @GetMapping("/allocations/{allocationId}/merge-candidates") - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, - @PathVariable long allocationId, @RequestParam(required = false) String customerId) { - return ApiResponse.success(service.mergeCandidates(scope(user, customerId), planNo, allocationId)); - } - - @PostMapping("/allocations/{allocationId}/merge") - public ApiResponse merge(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @PathVariable long allocationId, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.ConfirmMerge request) { - requireOperate(user); - return ApiResponse.success(service.merge(scope(user, customerId), planNo, allocationId, request)); - } - - @PostMapping("/complete") - public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId) { - requireOperate(user); - return ApiResponse.success(service.complete(scope(user, customerId), planNo)); - } - - @PostMapping("/unload-photos") - public ApiResponse uploadUnloadPhotos(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String planNo, - @RequestParam(required = false) String customerId, - @Valid @RequestBody PdaRequests.UnloadPhotos request) { - requireOperate(user); - return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); - } - - @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadUnloadPhotoFile( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, - @RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) { - requireOperate(user); - return ApiResponse.success(service.uploadUnloadPhotoFile( - scope(user, customerId), planNo, user.userId(), file)); - } - - private String scope(AuthenticatedUser user, String requestedCustomer) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { - if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant"); - } - return requestedCustomer; - } - return String.valueOf(user.tenantId()); - } - - private void requireOperate(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); - } - } -} 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 6adcf7ff5..fdb3e6d75 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 @@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests; import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses; import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -30,14 +31,14 @@ import java.util.List; @Tag(name = "Overseas PDA container receiving") @PreAuthorize("hasAuthority('lastleg:pda:operate')") public class ReceivingContainerController { - public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + public static final String OPERATE_PERMISSION = PdaWarehouseIdentity.OPERATE_PERMISSION; @Resource private ReceivingContainerService service; @GetMapping("/candidates") @Operation(summary = "List pending receiving containers across customers for the authenticated warehouse picker") public ApiResponse> candidates( @AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } @@ -45,14 +46,14 @@ public class ReceivingContainerController { @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = identity(user); + PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), containerNo, request)); } @@ -60,7 +61,7 @@ public class ReceivingContainerController { @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, @RequestPart("file") MultipartFile file) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); if (user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "PDA user ID is required"); return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file)); } @@ -68,13 +69,13 @@ public class ReceivingContainerController { @PostMapping("/current/sign") public ApiResponse sign(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Sign request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.sign(identity.operatorKey(), identity.operatorName(), request)); } @GetMapping("/current") public ApiResponse current(@AuthenticationPrincipal AuthenticatedUser user) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.current(identity.operatorKey())); } @@ -82,7 +83,7 @@ public class ReceivingContainerController { public ApiResponse resolveCarton( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } @@ -90,7 +91,7 @@ public class ReceivingContainerController { public ApiResponse resolveSku( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); } @@ -98,53 +99,38 @@ public class ReceivingContainerController { public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.PalletResolve request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.resolvePallet(identity.operatorKey(), request)); } @PostMapping("/current/receipts/bulk") public ApiResponse receiveBulk(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.BulkReceive request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.receiveBulk(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/receipts/carton") public ApiResponse receiveCarton(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.CartonReceive request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.receiveCarton(identity.operatorKey(), identity.operatorName(), request)); } @PostMapping("/current/complete") public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReceivingContainerRequests.Action request) { - Identity identity = currentIdentity(user); + PdaWarehouseIdentity identity = currentIdentity(user); return ApiResponse.success(service.complete(identity.operatorKey(), request)); } - private static Identity identity(AuthenticatedUser user) { - if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 - || !"PLATFORM".equals(user.appCode())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authenticated platform warehouse PDA identity is required"); - } - if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); - } - if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "PDA operator must be bound to one overseas warehouse"); - } - String operator = String.valueOf(user.userId()); - return new Identity(operator, - user.username() == null || user.username().isBlank() ? operator : user.username(), - user.overseasWarehouseId()); + private static PdaWarehouseIdentity identity(AuthenticatedUser user) { + return PdaWarehouseIdentity.require(user); } - private Identity currentIdentity(AuthenticatedUser user) { - Identity identity = identity(user); + private PdaWarehouseIdentity currentIdentity(AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); service.requireCurrentWarehouse(identity.operatorKey(), identity.overseasWarehouseId()); return identity; } - - private record Identity(String operatorKey, String operatorName, long overseasWarehouseId) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java new file mode 100644 index 000000000..cbbf5139b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.security; + +import com.baiyue.common.security.AuthenticatedUser; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public record PdaWarehouseIdentity(String operatorKey, String operatorName, long overseasWarehouseId) { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + public static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + public static PdaWarehouseIdentity require(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0 + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (!user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "WAREHOUSE_PICKER role is required"); + } + if (!user.permissions().contains(OPERATE_PERMISSION)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new PdaWarehouseIdentity(operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java new file mode 100644 index 000000000..aaeef8112 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public final class PdaWarehouseRequests { + private PdaWarehouseRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record HistoryQuery(@Min(1) long page, @Min(1) @Max(200) long size, String operationType) { + public HistoryQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java new file mode 100644 index 000000000..cb5b3921e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.pda.warehouse.dto; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaWarehouseResponses { + private PdaWarehouseResponses() { } + + public record InventoryScan(String queryCode, List items) { } + public record InventoryItem(String customerId, String customerName, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, String cartonMark, + long palletId, String palletCode, Long locationId, String locationCode, + long availablePackages, long holdPackages, long receivingPackages, + String status, long version) { } + public record Location(long locationId, String locationCode, String locationType, BigDecimal maxLoadKg, + String status, long occupiedPackages, int inventoryPositionCount) { } + public record Pallet(long palletId, String palletCode, String status, int version, + long locationId, String locationCode, long occupiedPackages, + int inventoryPositionCount, List inventory) { } + public record Operation(long operationId, String operationType, String businessKey, String status, + String summary, String customerId, LocalDateTime occurredAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java new file mode 100644 index 000000000..59e428c49 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.warehouse.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_pda_operation_history") +public class PdaOperationHistory { + @TableId(type = IdType.INPUT) + private Long id; + private Long overseasWarehouseId; + private String customerId; + private String operatorKey; + private String operationType; + private String businessKey; + private String status; + private String idempotencyKey; + private String requestHash; + private String summary; + private LocalDateTime occurredAt; + private LocalDateTime createdAt; +} 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 new file mode 100644 index 000000000..0fab0336f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.pda.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.model.PdaInventoryProjection; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +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," + + "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," + + "c.carton_mark,p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code,p.available_packages,p.hold_packages,p.receiving_packages," + + "p.status,p.version FROM ll_inventory_balance b JOIN ll_inventory_position p " + + "ON p.balance_id=b.id AND p.customer_id=b.customer_id " + + "LEFT JOIN ll_inbound_stock_receipt r ON r.inventory_balance_id=b.id AND r.inventory_position_id=p.id " + + "LEFT JOIN ll_inbound_plan_carton c ON c.customer_id=b.customer_id AND c.order_id=b.inbound_order_id " + + "AND c.warehouse_pallet_id=p.pallet_id AND c.carton_mark=#{code} AND c.status='RECEIVED' "; + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (UPPER(b.sku_code)=UPPER(#{code}) OR UPPER(r.sku_code)=UPPER(#{code}) " + + "OR b.inbound_code=#{code} OR c.carton_mark=#{code}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List scanInventory(@Param("warehouseId") String warehouseId, + @Param("code") String code); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List inventoryByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId, + @Param("code") String ignored); + + @Select(INVENTORY_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} " + + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.pallet_no,p.id") + List inventoryByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode, + @Param("code") String ignored); + + @Select("") + List history( + @Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType, @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long countHistory(@Param("warehouseId") long warehouseId, @Param("operatorKey") String operatorKey, + @Param("operationType") String operationType); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java new file mode 100644 index 000000000..a34b41d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.warehouse.model; + +import lombok.Data; + +@Data +public class PdaInventoryProjection { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String inboundCode; + private String cartonMark; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long availablePackages; + private Long holdPackages; + private Long receivingPackages; + private String status; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index b1d1b1803..b1afcaa80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -75,6 +75,15 @@ public class PlatformUsTruckOrderController { return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam String customerId, @PathVariable String orderNo, + @Valid @RequestBody UsTruckOrderRequests.Review request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.review(requiredCustomer(customerId), orderNo, operator(user), request)); + } + @PostMapping("/{orderNo}/receive-pallets") @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") @Operation(summary = "Receive pallets for a local truck order", description = "Required authority: " + OverseasPermissions.TRUCK_STATUS) @@ -85,6 +94,66 @@ public class PlatformUsTruckOrderController { return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } + @PostMapping("/{orderNo}/review-pallet-overage") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse reviewPalletOverage( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReviewPalletOverage request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.reviewPalletOverage( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/allocate-pallets") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse allocatePallets( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.AllocatePallets request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.allocatePallets( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/prepare-dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse prepareDispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.prepareDispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/dispatch") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse dispatch( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.dispatch( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/start-transit") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse startTransit( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.startTransit( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + + @PostMapping("/{orderNo}/complete") + @PreAuthorize("hasAuthority('" + OverseasPermissions.TRUCK_STATUS + "')") + public ApiResponse complete( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, + @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Command request) { + requirePermission(user, OverseasPermissions.TRUCK_STATUS); + return ApiResponse.success(service.complete( + requiredCustomer(customerId), orderNo, operator(user), request)); + } + private void requirePlatform(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java index 120364ad3..9dc8d9646 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -4,7 +4,10 @@ import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -71,7 +74,31 @@ public final class UsTruckOrderRequests { public record ChangeStatus( @NotNull @Min(0) Long version, - @NotBlank @Size(max = 32) String status, + @NotBlank @Pattern(regexp = "CANCELLED") String status, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Review( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record ReviewPalletOverage( + @NotNull @Min(0) Long version, + @NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record AllocatePallets( + @NotNull @Min(0) Long version, + @NotEmpty @Size(max = 500) List<@NotNull @Positive Long> palletIds, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) {} + + public record Command( + @NotNull @Min(0) Long version, + @NotBlank @Size(max = 255) String idempotencyKey, @Size(max = 1000) String remark) {} public record ReceivePallets( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java index 6169954b8..2370d6afe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -11,13 +11,14 @@ public final class UsTruckOrderResponses { public record Summary( Long id, String customerId, String orderNo, String transportType, String origin, String destination, String containerType, int palletCount, int packageCount, - int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount, + int receivedPalletCount, int allocatedPalletCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, String contactName, String contactInfo, LocalDate requestedDate, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {} public record History( - String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {} + String operationType, String fromStatus, String toStatus, String operatorId, + String idempotencyKey, String remark, LocalDateTime createdAt) {} public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status, Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java index 264ab0905..6f6d5eb79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -25,7 +25,6 @@ public class UsTruckOrder { private Integer packageCount; private Integer receivedPalletCount; private Integer allocatedPalletCount; - private Integer shippedPalletCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; private BigDecimal budgetAmount; @@ -37,8 +36,6 @@ public class UsTruckOrder { private String remark; private String idempotencyKey; private String requestHash; - private String receiptIdempotencyKey; - private String receiptRequestHash; private String createdBy; private Long version; private LocalDateTime createdAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java new file mode 100644 index 000000000..a9bfb4087 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.trucking.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_us_truck_order_operation") +public class UsTruckOrderOperation { + @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/trucking/entity/UsTruckOrderStatusHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java index be4fee492..3f4602de5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java @@ -13,9 +13,11 @@ public class UsTruckOrderStatusHistory { @TableId(type = IdType.INPUT) private Long id; private Long orderId; private String customerId; + private String operationType; private String fromStatus; private String toStatus; private String operatorId; + private String idempotencyKey; private String remark; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java new file mode 100644 index 000000000..92d12f53e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.trucking.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface UsTruckOrderOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_us_truck_order_operation WHERE customer_id=#{customerId} AND order_id=#{orderId} AND idempotency_key=#{key} LIMIT 1") + UsTruckOrderOperation findKey(@Param("customerId") String customerId, + @Param("orderId") Long orderId, + @Param("key") String key); +} 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 4dd11a600..7f336fd7f 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 @@ -7,9 +7,11 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; 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.baiyue.lastleg.security.OverseasPermissions; @@ -30,6 +32,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HexFormat; +import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Consumer; @@ -55,20 +58,12 @@ public class UsTruckOrderService { private static final Set STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); - private static final java.util.Map> TRANSITIONS = java.util.Map.ofEntries( - java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)), - java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)), - java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)), - java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)), - java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)), - java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)), - java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)), - java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)), - java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)), - java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED))); + private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, + PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH); private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); @Resource private UsTruckOrderMapper orders; + @Resource private UsTruckOrderOperationMapper operations; @Resource private UsTruckOrderPalletMapper pallets; @Resource private UsTruckOrderStatusHistoryMapper histories; @Resource private ObjectMapper objectMapper; @@ -90,7 +85,7 @@ public class UsTruckOrderService { order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); - order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0); + order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); @@ -103,7 +98,7 @@ public class UsTruckOrderService { if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } - history(order, null, PENDING_REVIEW, operatorId, request.remark(), now); + history(order, "CREATE", null, PENDING_REVIEW, operatorId, key, request.remark(), now); return detail(customerId, order.getOrderNo()); } @@ -150,7 +145,7 @@ public class UsTruckOrderService { setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); touch(order); orders.updateById(order); - history(order, order.getStatus(), order.getStatus(), operatorId, "UPDATE", LocalDateTime.now()); + history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); return detail(customerId, orderNo); } @@ -158,34 +153,37 @@ public class UsTruckOrderService { public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ChangeStatus request) { UsTruckOrder order = lock(customerId, orderNo); - 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 truck order status"); - String from = order.getStatus(); - if (!next.equals(from)) { - if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) { - throw conflict("Unsupported truck order transition: " + from + " -> " + next); - } - order.setStatus(next); - if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order); - touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); + UsTruckOrderResponses.Detail replay = replayOperation(order, "CANCEL", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!CANCELLED.equals(norm(request.status())) || !CANCELLABLE.contains(order.getStatus())) { + throw conflict("Only cancellable truck orders can use the generic status endpoint"); } - return detail(customerId, orderNo); + return transition(order, operatorId, "CANCEL", request.idempotencyKey(), request, + order.getStatus(), CANCELLED, request.remark()); + } + + @Transactional + public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Review request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending-review truck orders can be reviewed"); + String next = "APPROVE".equals(norm(request.decision())) ? WAITING_INBOUND : REJECTED; + if (WAITING_INBOUND.equals(next)) createPlannedPallets(order); + return transition(order, operatorId, "REVIEW", request.idempotencyKey(), request, + PENDING_REVIEW, next, request.remark()); } @Transactional public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReceivePallets request) { UsTruckOrder order = lock(customerId, orderNo); - String key = request.idempotencyKey().trim(); - String requestHash = hash(request); - if (key.equals(order.getReceiptIdempotencyKey())) { - if (!requestHash.equals(order.getReceiptRequestHash())) { - throw conflict("Receipt idempotency key was reused with different pallet data"); - } - return detail(customerId, orderNo); - } - if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); + UsTruckOrderResponses.Detail replay = replayOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) { throw conflict("Only waiting or receiving orders can receive pallets"); } @@ -220,18 +218,118 @@ public class UsTruckOrderService { } } order.setReceivedPalletCount(actual); - order.setReceiptIdempotencyKey(key); - order.setReceiptRequestHash(requestHash); String from = order.getStatus(); String next = actual > planned ? PALLET_OVERAGE_REVIEW : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; order.setStatus(next); touch(order); orders.updateById(order); - history(order, from, next, operatorId, request.remark(), now); + recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + @Transactional + public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.ReviewPalletOverage request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "REVIEW_PALLET_OVERAGE", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!PALLET_OVERAGE_REVIEW.equals(order.getStatus())) { + throw conflict("Only pallet-overage-review orders can be reviewed"); + } + boolean approve = "APPROVE".equals(norm(request.decision())); + List rows = pallets.listOrder(customerId, order.getId()); + LocalDateTime now = LocalDateTime.now(); + int accepted = value(order.getPalletCount()); + for (UsTruckOrderPallet pallet : rows) { + if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue; + pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + if (approve) { + accepted = value(order.getReceivedPalletCount()); + order.setPalletCount(accepted); + } else { + order.setReceivedPalletCount(accepted); + } + order.setStatus(WAITING_CONSOLIDATION); + touch(order); + orders.updateById(order); + recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now); + history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, + operatorId, request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.AllocatePallets request) { + UsTruckOrder order = lock(customerId, orderNo); + UsTruckOrderResponses.Detail replay = replayOperation(order, "ALLOCATE_PALLETS", + request.idempotencyKey(), request); + if (replay != null) return replay; + requireVersion(order, request.version()); + if (!Set.of(WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED).contains(order.getStatus())) { + throw conflict("Only waiting-consolidation or partially-allocated orders can allocate pallets"); + } + Set requested = new HashSet<>(request.palletIds()); + if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique"); + List rows = pallets.listOrder(customerId, order.getId()); + List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList(); + if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) { + throw conflict("Selected pallets must belong to the order and be received but unallocated"); + } + LocalDateTime now = LocalDateTime.now(); + for (UsTruckOrderPallet pallet : selected) { + pallet.setStatus("ALLOCATED"); + pallet.setUpdatedAt(now); + pallets.updateById(pallet); + } + int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count() + + selected.size(); + int accepted = value(order.getReceivedPalletCount()); + if (accepted < 1 || allocated > accepted) throw conflict("Allocated pallet count exceeds accepted pallets"); + String from = order.getStatus(); + String next = allocated == accepted ? ALLOCATED : PARTIALLY_ALLOCATED; + order.setAllocatedPalletCount(allocated); + order.setStatus(next); + touch(order); + orders.updateById(order); + recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now); + history(order, "ALLOCATE_PALLETS", from, next, operatorId, + request.idempotencyKey(), request.remark(), now); + return detail(customerId, orderNo); + } + + @Transactional + public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "PREPARE_DISPATCH", ALLOCATED, + PENDING_DISPATCH, request); + } + + @Transactional + public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request); + } + + @Transactional + public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request); + } + + @Transactional + public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId, + UsTruckOrderRequests.Command request) { + return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); + } + 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) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 8270d8f01..850071823 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -5,8 +5,7 @@ import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; import com.baiyue.lastleg.dispatch.controller.SmartDispatchController; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; -import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; +import com.baiyue.lastleg.inboundplan.pda.controller.ReceivingContainerController; import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -26,10 +25,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class Phase4BrowserApiBoundaryTest { @Test - void inboundPdaAndUnloadPhotoApisUseThePdaAccessDomain() { - assertTrue(basePath(InboundPdaController.class).startsWith("/api/pda/v1/")); - assertTrue(basePath(InboundPalletAssignmentController.class).startsWith("/api/pda/v1/")); - assertFalse(basePath(InboundPdaController.class).contains("/api/platform/")); + void onlyStableContainerReceivingUsesThePdaInboundAccessDomain() throws Exception { + assertTrue(basePath(ReceivingContainerController.class).startsWith("/api/pda/v1/")); + assertFalse(basePath(ReceivingContainerController.class).contains("/api/platform/")); + Path controllers = Path.of("src/main/java/com/baiyue/lastleg/inboundplan/pda/controller"); + String source = Files.walk(controllers).filter(path -> path.toString().endsWith(".java")) + .map(path -> { + try { return Files.readString(path); } + catch (Exception exception) { throw new IllegalStateException(exception); } + }).collect(Collectors.joining("\n")); + assertFalse(source.contains("/api/pda/v1/last-leg/inbound-plans/{planNo}")); + assertFalse(source.contains("@RequestParam(required = false) String customerId")); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java deleted file mode 100644 index b00867007..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPalletAssignmentControllerTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.baiyue.lastleg.inboundplan.pda.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests; -import com.baiyue.lastleg.inboundplan.pda.service.InboundPdaWorkflowService; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -class InboundPalletAssignmentControllerTest { - private final InboundPdaWorkflowService service = mock(InboundPdaWorkflowService.class); - private final InboundPalletAssignmentController controller = controller(); - private final PdaRequests.Assign request = new PdaRequests.Assign(1L, 2L, 3L, "assign-key"); - - @Test - void assignmentRequiresPdaOperatePermissionAndExplicitPlatformCustomer() { - assertThrows(ResponseStatusException.class, - () -> controller.assign(user(Set.of()), "PLAN-1", 4L, "100", request)); - assertThrows(ResponseStatusException.class, - () -> controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, null, request)); - - controller.assign(user(Set.of(InboundPdaController.OPERATE_PERMISSION)), "PLAN-1", 4L, "100", request); - - verify(service).assign("100", "PLAN-1", 4L, request); - } - - private AuthenticatedUser user(Set permissions) { - return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "PDA", permissions); - } - - private InboundPalletAssignmentController controller() { - InboundPalletAssignmentController value = new InboundPalletAssignmentController(); - ReflectionTestUtils.setField(value, "service", service); - return value; - } -} diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index bd977c3e0..2fd8d05f9 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1029,13 +1029,111 @@ export async function run(context) { const dynamicCandidatesResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const dynamicCandidates = context.apiSuccess(MODULE, '查询同柜动态增量候选', dynamicCandidatesResponse, '', OPTIONS) - const dynamicCandidate = Array.isArray(dynamicCandidates) ? dynamicCandidates.find((item) => - id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : null + const markerOwnedDynamicCandidates = Array.isArray(dynamicCandidates) ? dynamicCandidates.filter((item) => + id(item?.inboundId) === incrementalInboundId && Number(item?.availablePackageCount) > 0) : [] + const dynamicCandidate = markerOwnedDynamicCandidates[0] + const skippedExceptionCandidate = markerOwnedDynamicCandidates[1] + const partialExceptionCandidate = markerOwnedDynamicCandidates.find((item, index) => + index > 1 && Number(item?.availablePackageCount) >= 2) context.assert(MODULE, '动态增量候选保持客户、仓库和渠道隔离', dynamicCandidatesResponse, Boolean(dynamicCandidate?.inboundDetailId) && id(dynamicCandidate?.accountId) === tenantId && id(dynamicCandidate?.warehouseId) === warehouseId && dynamicCandidate?.channelCode === initial?.summary?.channelCode, `inboundId=${incrementalInboundId}, detailId=${id(dynamicCandidate?.inboundDetailId) || 'missing'}, available=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) + context.assert(MODULE, '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', dynamicCandidatesResponse, + Boolean(skippedExceptionCandidate?.inboundDetailId) && Boolean(partialExceptionCandidate?.inboundDetailId) + && id(skippedExceptionCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId) + && id(dynamicCandidate?.inboundDetailId) !== id(skippedExceptionCandidate?.inboundDetailId) + && id(dynamicCandidate?.inboundDetailId) !== id(partialExceptionCandidate?.inboundDetailId), + `candidateCount=${markerOwnedDynamicCandidates.length}, skipDetailId=${id(skippedExceptionCandidate?.inboundDetailId) || 'missing'}, partialDetailId=${id(partialExceptionCandidate?.inboundDetailId) || 'missing'}`, OPTIONS) + if (skippedExceptionCandidate?.inboundDetailId && partialExceptionCandidate?.inboundDetailId) { + const beforeSkipPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const beforeSkipPlan = context.apiSuccess(MODULE, 'SKIP 异常场景前读取计划即时版本', beforeSkipPlanResponse, '', OPTIONS) + const skipAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId: incrementalInboundId, inboundDetailId: id(skippedExceptionCandidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(beforeSkipPlan?.version), operationId: `${context.marker}-exception-skip-assign`.slice(0, 128), + }) + const skipAssignedPlan = context.apiSuccess(MODULE, '加入本轮待跳拣异常明细', skipAssignmentResponse, '', OPTIONS) + const skipPlanItem = skipAssignedPlan?.items?.find?.((item) => + id(item?.inboundDetailId) === id(skippedExceptionCandidate.inboundDetailId)) + const skipTaskResponse = await context.request('GET', taskDetailPath) + const skipTask = context.apiSuccess(MODULE, '读取待跳拣异常 PDA 明细', skipTaskResponse, '', OPTIONS) + const skipTaskItem = skipTask?.items?.find?.((item) => id(item?.loadingPlanItemId) === id(skipPlanItem?.id)) + const skipInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const skipInventory = context.apiSuccess(MODULE, '跳拣前刷新实时库存快照', skipInventoryResponse, '', OPTIONS) + const skipReason = `${context.marker}-箱唛破损待复核` + const skipBatchResponse = await request(context, 'POST', batchPath, { + warehouseId, loadingPlanId, containerBookingId, + expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken, + items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version), + reason: skipReason }], + }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) }) + const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS) + const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id)) + context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, + Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED' + && Number(skippedTaskItem?.pickedPackageCount) === 0 + && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING', + `itemStatus=${skippedTaskItem?.itemStatus || 'missing'}, picked=${skippedTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${skippedTask?.workflowStatus || 'missing'}`, OPTIONS) + + const beforePartialPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const beforePartialPlan = context.apiSuccess(MODULE, 'PARTIAL 异常场景前读取计划即时版本', beforePartialPlanResponse, '', OPTIONS) + const partialAssignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId: incrementalInboundId, inboundDetailId: id(partialExceptionCandidate.inboundDetailId), packageCount: 2, + expectedVersion: Number(beforePartialPlan?.version), operationId: `${context.marker}-exception-partial-assign`.slice(0, 128), + }) + const partialAssignedPlan = context.apiSuccess(MODULE, '加入本轮部分拣货异常明细', partialAssignmentResponse, '', OPTIONS) + const partialPlanItem = partialAssignedPlan?.items?.find?.((item) => + id(item?.inboundDetailId) === id(partialExceptionCandidate.inboundDetailId)) + const unresolvedTaskResponse = await context.request('GET', taskDetailPath) + const unresolvedTask = context.apiSuccess(MODULE, '读取存在未解决异常的 PDA 任务', unresolvedTaskResponse, '', OPTIONS) + const unresolvedTaskItem = unresolvedTask?.items?.find?.((item) => + id(item?.loadingPlanItemId) === id(partialPlanItem?.id)) + const operationsBeforeUnresolvedCompleteResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const operationsBeforeUnresolvedComplete = context.apiSuccess(MODULE, + '未解决异常完成前读取 PDA 操作审计基线', operationsBeforeUnresolvedCompleteResponse, '', OPTIONS) + const unresolvedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(unresolvedTask?.version), + operationId: `${context.marker}-complete-with-unresolved`.slice(0, 128), + remark: `${context.marker}-must-reject-unresolved`, + }, { 'Idempotency-Key': `${context.marker}-complete-with-unresolved-key`.slice(0, 128) }) + context.addStep(MODULE, '存在未解决异常明细时禁止确认装柜完成', unresolvedComplete, + unresolvedComplete.http === 200 && codeOf(unresolvedComplete) === 'A0409' + && String(unresolvedComplete.body?.message || '').includes('尚未处理'), + responseEvidence(unresolvedComplete), OPTIONS) + const afterUnresolvedCompleteTaskResponse = await context.request('GET', taskDetailPath) + const afterUnresolvedCompleteTask = context.apiSuccess(MODULE, + '未解决异常完成失败后回查 PDA 任务', afterUnresolvedCompleteTaskResponse, '', OPTIONS) + const operationsAfterUnresolvedCompleteResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/operations`) + const operationsAfterUnresolvedComplete = context.apiSuccess(MODULE, + '未解决异常完成失败后回查 PDA 操作审计', operationsAfterUnresolvedCompleteResponse, '', OPTIONS) + context.assert(MODULE, '未解决异常完成失败后 task/items/operation 全部零写入', afterUnresolvedCompleteTaskResponse, + Number(afterUnresolvedCompleteTask?.version) === Number(unresolvedTask?.version) + && taskItemsSignature(afterUnresolvedCompleteTask) === taskItemsSignature(unresolvedTask) + && operationsSignature(operationsAfterUnresolvedComplete) === operationsSignature(operationsBeforeUnresolvedComplete), + `taskVersion=${afterUnresolvedCompleteTask?.version ?? 'missing'}, operations=${operationsAfterUnresolvedComplete?.length ?? 'missing'}`, OPTIONS) + + const partialInventoryResponse = await context.request('GET', + `${pdaBase}/tasks/${canonicalTaskId}/inventory-snapshot?warehouseId=${encodeURIComponent(warehouseId)}`) + const partialInventory = context.apiSuccess(MODULE, '部分拣货前刷新实时库存快照', partialInventoryResponse, '', OPTIONS) + const partialBatchResponse = await request(context, 'POST', batchPath, { + warehouseId, loadingPlanId, containerBookingId, + expectedVersion: Number(afterUnresolvedCompleteTask?.version), + inventorySnapshotToken: partialInventory?.snapshotToken, + items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, + releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], + }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) }) + const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) + const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) + context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, + partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2 + && Number(partialTaskItem?.pickedPackageCount) === 1 + && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', + `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS) + } if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const planBeforeIncrement = context.apiSuccess(MODULE, '读取 LOADING 动态增量前计划', planBeforeIncrementResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 4d1662b28..d54cf72d4 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -458,6 +458,32 @@ test('phase3 loading proves stale inventory zero-write and defers final completi assert.doesNotMatch(loading, /complete-before-downstream/) }) +test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => { + const loading = source('./phase3-loading.mjs') + assertOrder(loading, [ + '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', + '加入本轮待跳拣异常明细', + 'SKIP 携带异常原因并原子解决待拣明细', + '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', + '加入本轮部分拣货异常明细', + '未解决异常完成前读取 PDA 操作审计基线', + '存在未解决异常明细时禁止确认装柜完成', + '未解决异常完成失败后 task/items/operation 全部零写入', + '合法 PARTIAL 解决未完成明细', + '部分拣货数量守恒并恢复最终可确认状态', + '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', + ]) + assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/) + assert.match(loading, /skipReason = `\$\{context\.marker\}-箱唛破损待复核`/) + assert.match(loading, /itemStatus === 'SKIPPED'[\s\S]*pickedPackageCount\) === 0/) + assert.match(loading, /action: 'PARTIAL', finalPickedPackageCount: 1/) + assert.match(loading, /plannedPackageCount\) === 2[\s\S]*pickedPackageCount\) === 1/) + assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/) + assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/) + assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/) + assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) +}) + test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { const entry = source('../admin-test-env-acceptance.mjs') const measurement = source('./inbound-measurement.mjs') diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7ede1b836..be951ca52 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -87,7 +87,8 @@ public class PlatformWarehouseResourceController { } @PostMapping("/pallets") - @Operation(summary = "Create a warehouse pallet", description = "Platform access required") + @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.PALLET_CREATE + "')") + @Operation(summary = "Create a warehouse pallet", description = "Required authority: " + OverseasWarehousePermissions.PALLET_CREATE) public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId, @Valid @RequestBody PalletRequests.SaveRequest request) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 6af72807f..65588f077 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -10,7 +10,6 @@ import java.util.Map; import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; class WarehouseResourcePermissionContractTest { private static final String PREFIX = "platform:overseas-warehouse:"; @@ -28,15 +27,9 @@ class WarehouseResourcePermissionContractTest { assertPermission(methods.get("deleteLocation"), PREFIX + "location:delete"); assertPermission(methods.get("pallets"), PREFIX + "pallet:list"); assertPermission(methods.get("pallet"), PREFIX + "pallet:list"); + assertPermission(methods.get("createPallet"), PREFIX + "pallet:create"); assertPermission(methods.get("updatePallet"), PREFIX + "pallet:edit"); assertPermission(methods.get("deletePallet"), PREFIX + "pallet:delete"); - - Method createPallet = Arrays.stream(PlatformWarehouseResourceController.class.getDeclaredMethods()) - .filter(method -> "createPallet".equals(method.getName())) - .findFirst() - .orElseThrow(); - assertNull(createPallet.getAnnotation(PreAuthorize.class)); - assertEquals("Platform access required", createPallet.getAnnotation(Operation.class).description()); } private static void assertPermission(Method method, String authority) { From cb78bc6b41ee375e90a0c14d74ebafc5df3615e9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 09:58:59 +0800 Subject: [PATCH 146/231] test: cover partial and concurrent pda completion --- ...e3-loading-dynamic-final-contract.test.mjs | 23 ++++++++ .../phase3-loading-finalize.mjs | 52 ++++++++++++++++--- .../admin-test-env-modules/phase3-loading.mjs | 9 ++++ .../phase3-producer-chain-contract.test.mjs | 4 ++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 8e604f641..39cdbb7f6 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -142,3 +142,26 @@ test('HTTP finalizer preserves failure evidence and verifies the settled slice', assert.match(finalize, /traceId=\$\{completeResponse\.traceId \|\| 'missing'\}/) assert.match(finalize, /settlementMode = remainderSlice \? 'PARTIAL' : 'FULL'/) }) + +test('HTTP finalizer races distinct completion keys and persists exactly one terminal audit', () => { + ordered(finalize, [ + '读取结算前 PDA 操作历史基线', + 'const completeAttempts = [1, 2].map', + 'const concurrentCompleteResponses = await Promise.all', + '并发完成后回查 PDA 操作审计', + '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + '订舱与五类证据等保留门禁齐备后完成装柜', + 'LOADED 完成请求 Idempotency-Key 重放保持终态', + '入库单终态 LOADED、版本递增且审计历史存在', + ]) + assert.match(finalize, /pda-final-complete-concurrent-\$\{index\}/) + assert.match(finalize, /p3-complete-concurrent-key-\$\{index\}/) + assert.match(finalize, /successfulCompleteIndexes\.length === 1/) + assert.match(finalize, /conflictedCompleteIndexes\.length === 1/) + assert.match(finalize, /codeOf\(response\) === 'A0409'/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.requestId\)/) + assert.match(finalize, /Boolean\(conflictedCompleteResponse\?\.traceId\)/) + assert.match(finalize, /operationsAfterConcurrent\.length === operationsBefore\.length \+ 1/) + assert.match(finalize, /concurrentCompleteOperations\.length === 1/) + assert.match(finalize, /concurrentCompleteOperations\[0\]\?\.operationId === completeBody\.operationId/) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index c77b313ae..05c4fbb18 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -129,13 +129,50 @@ export async function run(context) { packageCount: Number(item?.packageCount), picked: Number(item?.pickedPackageCount) })), }) const operationsBeforeSignature = JSON.stringify(operationsBefore || []) - const completeBody = { - expectedTaskVersion: Number(taskBefore.version), - operationId: `${context.marker}-pda-final-complete`.slice(0, 128), remark: context.marker, - } - const completeKey = `${context.marker}-p3-complete-after-downstream`.slice(0, 128) - const completeResponse = await request(context, 'POST', `${taskPath}/complete`, completeBody, - { 'Idempotency-Key': completeKey }) + const completeAttempts = [1, 2].map((index) => ({ + body: { + expectedTaskVersion: Number(taskBefore.version), + operationId: `${context.marker}-pda-final-complete-concurrent-${index}`.slice(0, 128), + remark: context.marker, + }, + key: `${context.marker}-p3-complete-concurrent-key-${index}`.slice(0, 128), + })) + const concurrentCompleteResponses = await Promise.all(completeAttempts.map((attempt) => + request(context, 'POST', `${taskPath}/complete`, attempt.body, + { 'Idempotency-Key': attempt.key }))) + const successfulCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === '200' ? [index] : []) + const conflictedCompleteIndexes = concurrentCompleteResponses.flatMap((response, index) => + response.http === 200 && codeOf(response) === 'A0409' ? [index] : []) + const conflictedCompleteResponse = conflictedCompleteIndexes.length === 1 + ? concurrentCompleteResponses[conflictedCompleteIndexes[0]] : null + const winningCompleteIndex = successfulCompleteIndexes[0] ?? -1 + const completeResponse = winningCompleteIndex >= 0 + ? concurrentCompleteResponses[winningCompleteIndex] : concurrentCompleteResponses[0] + const completeBody = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].body : completeAttempts[0].body + const completeKey = winningCompleteIndex >= 0 + ? completeAttempts[winningCompleteIndex].key : completeAttempts[0].key + const operationsAfterConcurrentResponse = await context.request('GET', `${taskPath}/operations`) + const operationsAfterConcurrent = context.apiSuccess(MODULE, + '并发完成后回查 PDA 操作审计', operationsAfterConcurrentResponse, '', OPTIONS) + const baselineCompleteOperationIds = new Set((operationsBefore || []) + .filter((item) => item?.actionType === 'COMPLETE_LOADING').map((item) => String(item?.operationId || ''))) + const concurrentCompleteOperations = (operationsAfterConcurrent || []).filter((item) => + item?.actionType === 'COMPLETE_LOADING' + && completeAttempts.some((attempt) => attempt.body.operationId === item?.operationId) + && !baselineCompleteOperationIds.has(String(item?.operationId || ''))) + const concurrentCompleteVerified = successfulCompleteIndexes.length === 1 + && conflictedCompleteIndexes.length === 1 + && Boolean(conflictedCompleteResponse?.requestId) && Boolean(conflictedCompleteResponse?.traceId) + && Array.isArray(operationsAfterConcurrent) && Array.isArray(operationsBefore) + && operationsAfterConcurrent.length === operationsBefore.length + 1 + && concurrentCompleteOperations.length === 1 + && concurrentCompleteOperations[0]?.operationId === completeBody.operationId + && id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt) + context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计', + completeResponse, concurrentCompleteVerified, + `results=${concurrentCompleteResponses.map((response) => `${response.http}/${codeOf(response)}`).join(',')}, success=${successfulCompleteIndexes.length}, conflict=${conflictedCompleteIndexes.length}, completeAudits=${concurrentCompleteOperations.length}, winner=${completeBody.operationId}`, OPTIONS) if (completeResponse.http !== 200 || codeOf(completeResponse) !== '200') { context.addStep(MODULE, '结算递增 inboundVersion 后 final complete 必须使用最新版本', completeResponse, false, `expected=success after settlement refresh; HTTP=${completeResponse.http}, code=${codeOf(completeResponse)}, message=${completeResponse.body?.message || 'missing'}, taskId=${taskId}, inboundId=${inboundId}, taskVersion=${taskBefore?.version ?? 'missing'}, inboundVersion=${version}, requestId=${completeResponse.requestId || 'missing'}, traceId=${completeResponse.traceId || 'missing'}`, OPTIONS) @@ -166,6 +203,7 @@ export async function run(context) { `taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS) return } + if (!concurrentCompleteVerified) return const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse, completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2fd8d05f9..19dc4aa1d 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1133,6 +1133,15 @@ export async function run(context) { && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', `itemStatus=${partialTaskItem?.itemStatus || 'missing'}, planned=${partialTaskItem?.plannedPackageCount ?? 'missing'}, picked=${partialTaskItem?.pickedPackageCount ?? 'missing'}, workflow=${partialTask?.workflowStatus || 'missing'}`, OPTIONS) + const exceptionOverviewResponse = await context.request('GET', + `/api/platform/v1/containers/${encodeURIComponent(containerBookingId)}/overview`) + const exceptionOverview = context.apiSuccess(MODULE, + '回查柜次 PDA 异常原因与已解决进度', exceptionOverviewResponse, '', OPTIONS) + context.assert(MODULE, 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', exceptionOverviewResponse, + exceptionOverview?.pda?.exceptionReasons?.includes?.(skipReason) + && Number(exceptionOverview?.pda?.unresolvedItemCount) === 0 + && exceptionOverview?.pda?.workflowStatus === 'AWAITING_REVIEW', + `exceptionReasons=${JSON.stringify(exceptionOverview?.pda?.exceptionReasons || [])}, unresolved=${exceptionOverview?.pda?.unresolvedItemCount ?? 'missing'}, workflow=${exceptionOverview?.pda?.workflowStatus || 'missing'}`, OPTIONS) } if (dynamicCandidate?.inboundDetailId) { const planBeforeIncrementResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d54cf72d4..6150e4891 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -471,6 +471,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea '未解决异常完成失败后 task/items/operation 全部零写入', '合法 PARTIAL 解决未完成明细', '部分拣货数量守恒并恢复最终可确认状态', + '回查柜次 PDA 异常原因与已解决进度', + 'SKIP 异常原因持久化且 PARTIAL 后无未解决明细', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /action: 'SKIP', version: Number\(skipTaskItem\?\.version\),\s*reason: skipReason/) @@ -481,6 +483,8 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /String\(unresolvedComplete\.body\?\.message \|\| ''\)\.includes\('尚未处理'\)/) assert.match(loading, /taskItemsSignature\(afterUnresolvedCompleteTask\) === taskItemsSignature\(unresolvedTask\)/) assert.match(loading, /operationsSignature\(operationsAfterUnresolvedComplete\) === operationsSignature\(operationsBeforeUnresolvedComplete\)/) + assert.match(loading, /exceptionOverview\?\.pda\?\.exceptionReasons\?\.includes\?\.\(skipReason\)/) + assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) }) From 52b9fc382ff111d1344f5703d4b87aaa2eca85b0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:31:04 +0800 Subject: [PATCH 147/231] feat: gate overseas receiving and harden phase4 workflows --- auth/AGENTS.md | 1 + .../controller/InternalAccountController.java | 16 ++ .../dto/OverseasSessionValidationDtos.java | 29 ++ .../OverseasSessionValidationService.java | 38 +++ .../auth/service/OverseasTokenService.java | 1 + ...ssionValidationControllerContractTest.java | 28 ++ .../auth/service/AuthServiceSmallJwtTest.java | 27 ++ .../OverseasSessionValidationServiceTest.java | 77 +++++ .../service/OverseasTokenServiceTest.java | 52 ++++ ...rstLegContainerShippingNodeRecordedV1.java | 8 +- ...egContainerShippingNodeRecordedV1Test.java | 6 + config/nacos/overseas/baiyue-common.yml | 4 + deploy/docker-compose.overseas.yml | 2 + deploy/scripts/deploy-built-service.sh | 7 + deploy/scripts/deploy-services.sh | 9 + deploy/sql/头程数据库统一增量-MySQL8.sql | 75 +++++ deploy/sql/海外区域统一增量-MySQL8.sql | 68 +++++ .../service/OutboundDispatchService.java | 5 + .../CarrierScheduleQueryRepository.java | 16 ++ .../CarrierScheduleQueryScheduler.java | 34 +-- .../service/CarrierScheduleQueryService.java | 49 +++- .../openapi/phase3-outbound-dispatch-v1.yaml | 3 +- .../dispatch/OutboundDispatchServiceTest.java | 19 ++ .../OutboundDispatchSqlContractTest.java | 1 + .../CarrierScheduleQuerySqlContractTest.java | 14 + .../CarrierScheduleNodeOutboxServiceTest.java | 6 +- .../CarrierScheduleQuerySchedulerTest.java | 39 +++ .../CarrierScheduleQueryServiceTest.java | 66 +++++ gateway-overseas/AGENTS.md | 1 + .../gateway/config/GatewaySecurityConfig.java | 4 +- .../PdaSessionValidationProperties.java | 25 ++ .../config/RequiredClaimsJwtValidator.java | 6 +- .../HttpPdaSessionValidationClient.java | 46 +++ .../filter/PdaSessionValidationClient.java | 10 + .../filter/PdaSessionValidationFilter.java | 92 ++++++ .../src/main/resources/application.yml | 4 + ...ssionValidationDeploymentContractTest.java | 43 +++ .../RequiredClaimsJwtValidatorTest.java | 15 +- .../PdaSessionValidationFilterTest.java | 119 ++++++++ .../main/resources/db/lastleg-permissions.sql | 75 +++++ ...erseasPhase4PermissionSqlContractTest.java | 37 +++ .../PlatformUserManagementServiceTest.java | 28 ++ .../controller/LastlegOrderController.java | 11 +- .../ReceivingContainerController.java | 2 +- .../gateway/HttpWarehouseResourceGateway.java | 28 ++ .../pda/gateway/WarehouseResourceGateway.java | 8 + .../service/ReceivingContainerService.java | 20 +- .../dto/LocalLooseboardRequests.java | 1 + .../dto/LocalLooseboardResponses.java | 2 +- .../entity/LocalLooseboardHistory.java | 1 + .../entity/LocalLooseboardOperation.java | 22 ++ .../LocalLooseboardOperationMapper.java | 15 + .../service/LocalLooseboardService.java | 31 ++- .../controller/PdaWarehouseController.java | 53 ++++ .../warehouse/mapper/PdaWarehouseMapper.java | 2 +- .../service/PdaWarehouseService.java | 101 +++++++ .../dto/OverseasProcurementRequests.java | 6 +- .../dto/OverseasProcurementResponses.java | 2 +- .../entity/OverseasProcurementOrder.java | 2 + .../OverseasProcurementStatusHistory.java | 1 + .../service/OverseasProcurementService.java | 27 +- .../service/LoadingCompletedConsumer.java | 5 + .../entity/ContainerShippingGate.java | 36 +++ .../mapper/ContainerShippingGateMapper.java | 29 ++ .../ContainerShippingNodeConsumer.java | 123 ++++++++ .../trucking/service/UsTruckOrderService.java | 82 +++++- .../src/main/resources/db/lastleg.sql | 61 +++- .../openapi/overseas-business-v1.yaml | 262 +++++++++++++++++- .../LastlegOrderControllerTest.java | 2 + .../pda/ReceivingContainerContractTest.java | 9 +- .../ReceivingContainerControllerTest.java | 6 +- ...ReceivingContainerServiceBehaviorTest.java | 26 ++ .../LocalLooseboardSchemaContractTest.java | 2 + .../service/LocalLooseboardServiceTest.java | 61 +++- .../warehouse/PdaWarehouseContractTest.java | 78 ++++++ .../service/PdaWarehouseServiceTest.java | 71 +++++ ...verseasProcurementOpenApiContractTest.java | 10 + ...OverseasProcurementSchemaContractTest.java | 3 + .../OverseasProcurementControllerTest.java | 4 +- .../OverseasProcurementServiceTest.java | 27 ++ .../SchemaChineseCommentContractTest.java | 13 +- .../service/LoadingCompletedConsumerTest.java | 5 +- .../ContainerShippingNodeConsumerTest.java | 118 ++++++++ .../UsTruckOrderSchemaContractTest.java | 6 +- .../UsTruckOrderControllerTest.java | 27 +- .../service/UsTruckOrderServiceTest.java | 234 +++++++++------- .../phase3-warehouse-rbac.mjs | 35 ++- ...canonical-warehouse-rbac-contract.test.mjs | 39 +++ scripts/db/incremental.sql | 9 + ...ternalWarehouseResourceLockController.java | 18 ++ .../dto/WarehouseLocationResolveResponse.java | 6 + .../service/WarehouseResourceService.java | 20 ++ .../service/WarehouseResourceServiceTest.java | 13 + 93 files changed, 2779 insertions(+), 201 deletions(-) create mode 100644 auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java create mode 100644 auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/PdaSessionValidationProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/PdaSessionValidationDeploymentContractTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/filter/PdaSessionValidationFilterTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java create mode 100644 scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseLocationResolveResponse.java 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); From a6be840588ee9a0fd062dec4a7e9b170c55cbf30 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:39:43 +0800 Subject: [PATCH 148/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 1 + .../phase4-canonical-warehouse-rbac-contract.test.mjs | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9f35980e2..271ad12d9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16395,6 +16395,7 @@ CALL baiyue_iam.assert_overseas_pda_picker_permission(); DROP PROCEDURE baiyue_iam.assert_overseas_pda_picker_permission; -- 四期仓库后台角色严格最小授权:list 同时覆盖列表与详情,只读角色不得获得任何写权限。 +USE baiyue_iam; DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission; CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission ( role_code VARCHAR(64) NOT NULL, 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 index c1fdaa0fe..d36246ae5 100644 --- 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 @@ -4,6 +4,7 @@ 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 unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') const listCodes = [ 'platform:overseas-warehouse:last-mile-order:list', @@ -37,3 +38,11 @@ test('picker stays isolated to the dedicated overseas PDA authority', () => { assert.match(iamSql, /role\.role_code='WAREHOUSE_PICKER'/) assert.match(iamSql, /permission\.permission_code='lastleg:pda:operate'/) }) + +test('unified migration creates the phase-four role matrix in the IAM schema used by its assertion routine', () => { + assert.match( + unifiedSql, + /USE baiyue_iam;\s*DROP TEMPORARY TABLE IF EXISTS tmp_phase4_warehouse_role_permission;\s*CREATE TEMPORARY TABLE tmp_phase4_warehouse_role_permission/, + ) + assert.match(unifiedSql, /CREATE PROCEDURE baiyue_iam\.assert_phase4_warehouse_role_minimum\(\)/) +}) From 2a16c91c89928bdf7782b7801fa309a383f2dee3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:48:10 +0800 Subject: [PATCH 149/231] test: sequence pickup readiness before overseas receiving --- scripts/admin-test-env-acceptance.mjs | 7 +- .../phase4-carrier-nodes-contract.test.mjs | 14 +++- .../phase4-carrier-nodes.mjs | 66 ++++++++++++++++++- .../phase4-carrier-pickup-ready.mjs | 5 ++ 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 51ba55c58..0c3a005ff 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -29,12 +29,13 @@ const MODULE_DEPENDENCIES = new Map([ ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], - ['phase4-overseas-pda', ['phase3-loading-finalize']], + ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], + ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], ['phase4-overseas-client-services', ['phase4-overseas-pda']], ['phase4-overseas-dispatch', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], - ['phase4-carrier-nodes', ['phase3-carrier-schedule-queries']], + ['phase4-carrier-nodes', ['phase4-overseas-pda']], ]) function stableTopologicalOrder(modules) { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 2b88fe588..4a26efff9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -31,13 +31,25 @@ test('focused carrier node execution injects the complete outbound-confirm produ 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-port-schedules', 'truck-appointments', 'service-center-flow', 'phase3-outbound-dispatch', - 'phase3-carrier-schedule-queries', 'phase4-carrier-nodes'] + 'phase3-carrier-schedule-queries', 'phase4-carrier-pickup-ready', + 'phase4-overseas-pda', 'phase4-carrier-nodes'] for (const module of required) assert.ok(modules.includes(module), module) for (let index = 1; index < required.length; index += 1) { assert.ok(modules.indexOf(required[index - 1]) < modules.indexOf(required[index]), required[index]) } }) +test('real lifecycle opens the shipping gate before PDA and records pickup and empty return afterwards', () => { + const modules = normalizeModuleOrder(['phase4-overseas-pda', 'phase4-carrier-nodes']) + const pickup = modules.indexOf('phase4-carrier-pickup-ready') + const pda = modules.indexOf('phase4-overseas-pda') + const completion = modules.indexOf('phase4-carrier-nodes') + assert.ok(pickup >= 0 && pickup < pda) + assert.ok(pda < completion) + assert.match(source, /phase4CarrierPickupReady/) + assert.match(source, /STANDARD_NODES\.slice\(STANDARD_NODES\.indexOf\('PICKED_UP'\)\)/) +}) + test('canonical states, ten ordered nodes, exception codes and complete payload are frozen', () => { assert.deepEqual(QUERY_STATUSES, ['READY', 'MANUAL_REQUIRED', 'STOPPED']) assert.deepEqual(STANDARD_NODES, [ diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index bd0bea3a9..903cb0da3 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -100,7 +100,7 @@ function blocker(context, name, evidence, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) } -export async function run(context) { +export async function runUntilPickupReady(context) { const carriersResponse = await context.request('GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '查询船司只读辅助信息', carriersResponse, '', OPTIONS) context.assert(MODULE, '船司辅助信息结构稳定', carriersResponse, @@ -188,7 +188,8 @@ export async function run(context) { const frozenLatestActualMs = frozenLatestActual == null ? Number.NaN : Date.parse(`${frozenLatestActual}Z`) const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 - for (let index = 0; index < STANDARD_NODES.length; index += 1) { + const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY') + for (let index = 0; index <= pickupReadyIndex; index += 1) { const standardNode = STANDARD_NODES[index] const actualAt = localIso(new Date(baseActual + index * 60_000)) const body = manualNodePayload({ expectedVersion: version, standardNode, @@ -214,6 +215,67 @@ export async function run(context) { detail = updated } + const pickupTask = detail.query.query + const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) + const expectedPickupNodes = STANDARD_NODES.slice(0, pickupReadyIndex + 1) + context.assert(MODULE, 'PDA开始前已覆盖至PICKUP_READY且任务尚未停止', detailResponse, + expectedPickupNodes.every((node) => actualNodes.has(node)) && pickupTask.status !== 'STOPPED' + && pickupTask.latestActualNode === 'PICKUP_READY', + `actualNodes=${[...actualNodes].join(',')}, status=${pickupTask.status}`, OPTIONS) + context.createdResources.phase4CarrierPickupReady = { + marker: context.marker, queryId, version: Number(pickupTask.version), + latestActualAt: pickupTask.latestActualAt, ready: true, + } +} + +export async function run(context) { + let pickup = context.createdResources?.phase4CarrierPickupReady + if (pickup?.marker !== context.marker || pickup?.ready !== true) { + await runUntilPickupReady(context) + pickup = context.createdResources?.phase4CarrierPickupReady + } + if (!pickup?.queryId) return + + const queryId = id(pickup.queryId) + const detailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(queryId)}`) + let detail = context.apiSuccess(MODULE, 'PDA收货完成后恢复本轮船节点任务', detailResponse, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, detailResponse, detail, '恢复任务详情DTO完整') + const pickupTask = detail?.query?.query + if (pickupTask?.latestActualNode !== 'PICKUP_READY' || pickupTask?.status === 'STOPPED') { + blocker(context, 'PDA收货后船节点未停留在PICKUP_READY,禁止伪造后续节点', + `latest=${pickupTask?.latestActualNode || 'missing'}, status=${pickupTask?.status || 'missing'}`, detailResponse) + return + } + + let version = Number(pickupTask.version) + const frozenLatestActualMs = Date.parse(`${pickupTask.latestActualAt}Z`) + const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000 + const remainingNodes = STANDARD_NODES.slice(STANDARD_NODES.indexOf('PICKED_UP')) + for (let index = 0; index < remainingNodes.length; index += 1) { + const standardNode = remainingNodes[index] + const actualAt = localIso(new Date(baseActual + index * 60_000)) + const body = manualNodePayload({ expectedVersion: version, standardNode, + observedAt: localIso(new Date()), actualAt, marker: context.marker, + index: STANDARD_NODES.indexOf(standardNode) + 1 }) + const key = `${context.marker}-carrier-actual-${String(STANDARD_NODES.indexOf(standardNode) + 1).padStart(2, '0')}`.slice(0, 128) + const response = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, body, + context.token, { 'Idempotency-Key': key }) + const updated = context.apiSuccess(MODULE, `PDA完成后补录实际节点${standardNode}`, response, + `queryId=${queryId}`, OPTIONS) + assertDetail(context, response, updated, `实际节点${standardNode}响应DTO完整`) + const updatedTask = updated?.query?.query + const isEmptyReturned = standardNode === 'EMPTY_RETURNED' + context.assert(MODULE, `${standardNode}实际事实单调推进且停止语义正确`, response, + updatedTask?.latestActualNode === standardNode && updatedTask?.latestActualAt === actualAt + && (isEmptyReturned + ? updatedTask.status === 'STOPPED' && updatedTask.stopReason === '已还柜' && updatedTask.stoppedAt != null + : updatedTask.status !== 'STOPPED'), + `status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS) + version = Number(updatedTask.version) + detail = updated + } + const finalTask = detail.query.query const actualNodes = new Set(detail.nodes.filter((item) => item.actualAt != null).map((item) => item.standardNode)) context.assert(MODULE, '最终详情覆盖十标准实际节点且只有EMPTY_RETURNED触发停止', detailResponse, diff --git a/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs new file mode 100644 index 000000000..1201dc813 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-carrier-pickup-ready.mjs @@ -0,0 +1,5 @@ +import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' + +export async function run(context) { + await runUntilPickupReady(context) +} From 45a3b9adfdf3f2adc5197879440d8c4d3ba35042 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 10:50:58 +0800 Subject: [PATCH 150/231] test: verify pda warehouse queries after receiving --- .../phase4-overseas-pda-contract.test.mjs | 5 ++ .../phase4-overseas-pda.mjs | 46 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 87c2addb0..6b0e61040 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -24,6 +24,9 @@ test('PDA module covers the container-number receiving contract and excludes out for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + for (const token of ['/inventory/resolve', '/locations/resolve', '/pallets/resolve', '/operations?page=1&size=50']) { + assert.ok(source.includes(token), token) + } for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role) for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) assert.match(source, /同键同载荷重放/) @@ -50,6 +53,8 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) + assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/) + assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 9a57ec520..b9edf6c8f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -117,8 +117,9 @@ async function createPallet(context, inbound, index) { const warehouseId = id(inbound?.overseasWarehouseId) if (!longId(warehouseId)) return null const suffix = markerSuffix(context) + const locationCode = `PDA-${suffix}-${index + 1}`.slice(0, 64) const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + zoneCode: `PDA-${index + 1}`, locationCode, locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`, }) context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS) @@ -133,7 +134,44 @@ async function createPallet(context, inbound, index) { }) context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) const palletId = id(pallet?.body?.data) - return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null + return longId(palletId) ? { warehouseId, locationId, locationCode, palletId, palletCode, retained: true } : null +} + +async function verifyWarehouseQueries(context, token, inboundCode, pallet) { + const inventory = await pda(context, token, 'POST', '完成入库后按入仓码现场查询库存', + '/api/pda/v1/last-leg/inventory/resolve', { code: inboundCode }) + context.assert(MODULE, '现场库存查询只返回Token绑定仓且包含库存位置', inventory.response, + success(inventory.response) && inventory.data?.queryCode === inboundCode + && Array.isArray(inventory.data?.items) && inventory.data.items.length > 0 + && inventory.data.items.every((item) => id(item?.palletId) === pallet.palletId + && id(item?.locationId) === pallet.locationId && Number(item?.availablePackages) > 0), + `items=${inventory.data?.items?.length ?? 'missing'}; ${trace(inventory.response)}`, OPTIONS) + + const location = await pda(context, token, 'POST', '完成入库后扫码查询库位', + '/api/pda/v1/last-leg/locations/resolve', { code: pallet.locationCode }) + context.assert(MODULE, '库位查询返回当前仓真实占用汇总', location.response, + success(location.response) && id(location.data?.locationId) === pallet.locationId + && location.data?.locationCode === pallet.locationCode + && Number(location.data?.occupiedPackages) > 0 && Number(location.data?.inventoryPositionCount) > 0, + `locationId=${id(location.data?.locationId) || 'missing'}; ${trace(location.response)}`, OPTIONS) + + const resolvedPallet = await pda(context, token, 'POST', '完成入库后扫码查询托盘内容', + '/api/pda/v1/last-leg/pallets/resolve', { code: pallet.palletCode }) + context.assert(MODULE, '托盘查询返回当前位置与库存明细', resolvedPallet.response, + success(resolvedPallet.response) && id(resolvedPallet.data?.palletId) === pallet.palletId + && id(resolvedPallet.data?.locationId) === pallet.locationId + && Array.isArray(resolvedPallet.data?.inventory) && resolvedPallet.data.inventory.length > 0 + && Number(resolvedPallet.data?.occupiedPackages) > 0, + `palletId=${id(resolvedPallet.data?.palletId) || 'missing'}; ${trace(resolvedPallet.response)}`, OPTIONS) + + const history = await pda(context, token, 'GET', '查询当前仓管PDA作业历史', + '/api/pda/v1/last-leg/operations?page=1&size=50') + const historyRows = records(history.data) + context.assert(MODULE, '作业历史包含本轮柜号收货动作且分页稳定', history.response, + success(history.response) && historyRows.length > 0 + && historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_')) + && Number(history.data?.page) === 1 && Number(history.data?.size) === 50, + `records=${historyRows.length}; ${trace(history.response)}`, OPTIONS) } async function uploadPhotos(context, token) { @@ -395,6 +433,10 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + const firstInbound = current.data.inbounds.find((item) => pallets.has(item.inboundCode)) + if (firstInbound) { + await verifyWarehouseQueries(context, token, firstInbound.inboundCode, pallets.get(firstInbound.inboundCode)) + } } } From 042d0b738a91390f35a2619f9f445e81aad62ed8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:20:36 +0800 Subject: [PATCH 151/231] feat: complete overseas pda warehouse operations --- deploy/sql/海外区域统一增量-MySQL8.sql | 152 ++++++ .../service/InternalBusinessFileService.java | 3 +- .../InternalBusinessFileServiceTest.java | 16 + lastleg-service/AGENTS.md | 1 + .../PdaPalletOperationController.java | 58 ++ .../dto/PalletOperationRequests.java | 42 ++ .../dto/PalletOperationResponses.java | 23 + .../palletops/entity/PdaPalletOperation.java | 25 + .../gateway/HttpPalletPhysicalGateway.java | 75 +++ .../gateway/PalletPhysicalGateway.java | 16 + .../mapper/PdaPalletOperationMapper.java | 45 ++ .../palletops/model/PalletInventoryRow.java | 27 + .../service/PdaPalletOperationService.java | 506 ++++++++++++++++++ .../PdaInventoryMoveController.java | 30 ++ .../pda/move/dto/PdaInventoryMoveRequest.java | 13 + .../move/dto/PdaInventoryMoveResponse.java | 26 + .../pda/move/entity/PdaInventoryMove.java | 35 ++ .../move/mapper/PdaInventoryMoveMapper.java | 25 + .../move/service/PdaInventoryMoveService.java | 192 +++++++ .../controller/PdaStocktakeController.java | 61 +++ .../stocktake/dto/PdaStocktakeRequests.java | 28 + .../stocktake/dto/PdaStocktakeResponses.java | 20 + .../stocktake/entity/PdaStocktakeAction.java | 24 + .../stocktake/entity/PdaStocktakeItem.java | 35 ++ .../stocktake/entity/PdaStocktakeTask.java | 32 ++ .../mapper/PdaStocktakeActionMapper.java | 15 + .../mapper/PdaStocktakeInventoryMapper.java | 20 + .../mapper/PdaStocktakeItemMapper.java | 66 +++ .../mapper/PdaStocktakeTaskMapper.java | 51 ++ .../stocktake/model/StocktakeCandidate.java | 19 + .../service/PdaStocktakeService.java | 323 +++++++++++ .../pda/PdaRelabelMapperConfiguration.java | 9 + .../pda/controller/PdaRelabelController.java | 127 +++++ .../relabel/pda/dto/PdaRelabelRequests.java | 45 ++ .../relabel/pda/dto/PdaRelabelResponses.java | 37 ++ .../relabel/pda/entity/PdaRelabelHistory.java | 24 + .../pda/entity/PdaRelabelOperation.java | 22 + .../relabel/pda/entity/PdaRelabelTask.java | 37 ++ .../pda/entity/PdaRelabelUnitFact.java | 26 + .../gateway/HttpRelabelPhotoFileGateway.java | 109 ++++ .../pda/gateway/RelabelPhotoFileGateway.java | 10 + .../pda/mapper/PdaRelabelHistoryMapper.java | 9 + .../pda/mapper/PdaRelabelOperationMapper.java | 13 + .../pda/mapper/PdaRelabelTaskMapper.java | 30 ++ .../pda/mapper/PdaRelabelUnitFactMapper.java | 13 + .../pda/service/PdaRelabelService.java | 480 +++++++++++++++++ .../src/main/resources/db/lastleg.sql | 150 ++++++ .../openapi/overseas-pda-operations-v1.yaml | 256 +++++++++ .../HttpWarehouseResourceGatewayTest.java | 1 + ...PalletOperationControllerContractTest.java | 27 + .../PdaPalletOperationServiceTest.java | 124 +++++ .../PdaInventoryOperationContractTest.java | 41 ++ .../pda/PdaOperationsOpenApiContractTest.java | 83 +++ .../service/PdaInventoryMoveServiceTest.java | 141 +++++ .../service/PdaStocktakeServiceTest.java | 146 +++++ .../controller/PdaRelabelContractTest.java | 86 +++ .../pda/service/PdaRelabelServiceTest.java | 245 +++++++++ scripts/db/incremental.sql | 35 ++ ...InternalPdaPalletTransitionController.java | 39 ++ .../InternalWarehousePalletController.java | 1 + .../dto/PalletTransitionRequests.java | 20 + .../dto/PalletTransitionResponse.java | 16 + .../WarehousePalletOperationEntity.java | 29 + .../mapper/WarehousePalletMapper.java | 3 +- .../WarehousePalletTransitionMapper.java | 32 ++ .../WarehousePalletTransitionService.java | 106 ++++ .../src/main/resources/db/warehouse.sql | 14 + .../SchemaChineseCommentContractTest.java | 2 +- .../WarehousePalletTransitionServiceTest.java | 91 ++++ 69 files changed, 4679 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java create mode 100644 lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 4748e0dba..a64450395 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -677,6 +677,125 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) ) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', version INT 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 '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_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')); @@ -689,6 +808,20 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), 'SELECT 1', @@ -713,4 +846,23 @@ CALL assert_phase4_warehouse_pallet_ready(); DROP PROCEDURE assert_phase4_warehouse_pallet_ready; USE baiyue_lastleg; +DROP PROCEDURE IF EXISTS assert_phase4_pda_operations_ready; +DELIMITER $$ +CREATE PROCEDURE assert_phase4_pda_operations_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastleg' AND table_name IN ( + 'll_pda_pallet_operation','ll_pda_stocktake_task','ll_pda_stocktake_item', + 'll_pda_stocktake_action','ll_pda_inventory_move','ll_relabel_pda_task', + 'll_relabel_pda_unit_fact','ll_relabel_pda_operation','ll_relabel_pda_history')) <> 9 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA盘点、移库、托盘或换标表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_lastlag_warehouse' AND table_name='wh_pallet_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA实物托盘操作表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_phase4_pda_operations_ready(); +DROP PROCEDURE assert_phase4_pda_operations_ready; SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index d320f4aaa..86268a298 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -23,7 +23,8 @@ public class InternalBusinessFileService { "LOADING_CUSTOMS_DOCUMENT", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", - "OVERSEAS_UNLOAD_PHOTO"); + "OVERSEAS_UNLOAD_PHOTO", + "OVERSEAS_RELABEL_PHOTO"); @Resource private FileObjectMapper files; @Resource private BusinessFileStorage storage; diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 7b8c7bcd6..f0cc94a06 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -80,6 +80,22 @@ class InternalBusinessFileServiceTest { verifyNoInteractions(files, storage); } + @Test void overseasRelabelPhotoPersistsExactTaskOwnership() { + MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes()); + when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored( + "business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg")); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1; + }); + + var result = service.upload(100L, "OVERSEAS_RELABEL_PHOTO", "81", 7L, file); + + assertThat(result).extracting(InternalBusinessFileService.Metadata::tenantId, + InternalBusinessFileService.Metadata::businessType, + InternalBusinessFileService.Metadata::businessId) + .containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81"); + } + @Test void unsupportedBusinessTypeFailsClosed() { assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L, new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes()))) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index d2853f74d..7adcf9f70 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -26,6 +26,7 @@ | PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | +| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java new file mode 100644 index 000000000..def4fce41 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.service.PdaPalletOperationService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +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.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/pallet-operations") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaPalletOperationController { + @Resource private PdaPalletOperationService service; + + @PostMapping("/pallets/resolve") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Scan request) { + return ApiResponse.success(service.resolve(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/assemble") + public ApiResponse assemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Assemble request) { + return ApiResponse.success(service.assemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/disassemble") + public ApiResponse disassemble( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Disassemble request) { + return ApiResponse.success(service.disassemble(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/swap") + public ApiResponse swap( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Swap request) { + return ApiResponse.success(service.swap(PdaWarehouseIdentity.require(user), request)); + } + + @PostMapping("/unbind") + public ApiResponse unbind( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PalletOperationRequests.Unbind request) { + return ApiResponse.success(service.unbind(PdaWarehouseIdentity.require(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java new file mode 100644 index 000000000..1b804c6ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.palletops.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PalletOperationRequests { + private PalletOperationRequests() { } + + public record Scan(@NotBlank @Size(max = 128) String code) { } + public record PalletExpectation(@NotBlank @Size(max = 128) String palletCode, + @PositiveOrZero int expectedVersion) { } + public record MoveLine(@Positive long positionId, @Positive long packages, + @PositiveOrZero long expectedPositionVersion) { } + public record TargetMove(@Valid PalletExpectation target, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines) { } + + public record Assemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation target, + @NotEmpty @Size(max = 20) List<@Valid PalletExpectation> sources, + @NotEmpty @Size(max = 200) List<@Valid MoveLine> lines, + @Size(max = 500) String reason) { } + + public record Disassemble(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @NotEmpty @Size(max = 20) List<@Valid TargetMove> targets, + @Size(max = 500) String reason) { } + + public record Swap(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Valid PalletExpectation target, + @Size(max = 500) String reason) { } + + public record Unbind(@NotBlank @Size(max = 128) String idempotencyKey, + @Valid PalletExpectation source, + @Size(max = 500) String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java new file mode 100644 index 000000000..c0cd61ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.palletops.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PalletOperationResponses { + private PalletOperationResponses() { } + + public record InventoryLine(String customerId, String customerName, long balanceId, long positionId, + String inboundCode, String skuCode, String productName, + long availablePackages, long holdPackages, long allocatedPackages, + long pickedPackages, long receivingPackages, long positionVersion) { } + public record PalletContent(long palletId, String palletCode, String status, int palletVersion, + long locationId, String locationCode, String destinationWarehouseCode, + long totalPackages, List inventory) { } + public record Operation(long operationId, String operationType, String idempotencyKey, + String status, boolean replayed, LocalDateTime completedAt, + List pallets) { + public Operation asReplay() { + return new Operation(operationId, operationType, idempotencyKey, status, true, completedAt, pallets); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java new file mode 100644 index 000000000..7d4e1a9be --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.palletops.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_pda_pallet_operation") +public class PdaPalletOperation { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String operationType; + private String requestHash; + private String status; + private String resultJson; + private String remoteTransitionsJson; + private String lastError; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java new file mode 100644 index 000000000..c93045fd4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.palletops.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public PalletReference resolve(String palletCode) { + ResolveEnvelope response = client.get().uri("/internal/v1/warehouse-pallets/resolve/{code}", palletCode) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected pallet scan: HTTP " + result.getStatusCode()); + }).body(ResolveEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no pallet"); + return response.data(); + } + + @Override + public LocationReference resolveLocation(long warehouseId, long locationId) { + LocationEnvelope 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() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Warehouse rejected location lookup: HTTP " + result.getStatusCode()); + }).body(LocationEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no location"); + return response.data(); + } + + @Override + public Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason) { + Map body = new LinkedHashMap<>(); + body.put("warehouseId", warehouseId); body.put("expectedVersion", pallet.version()); + body.put("targetStatus", targetStatus); body.put("targetLocationId", targetLocationId); + body.put("operationKey", operationKey); if (reason != null) body.put("reason", reason); + TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}", pallet.palletId()) + .header("X-Internal-Service-Token", internalToken).body(body).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode()); + }).body(TransitionEnvelope.class); + if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition"); + return response.data(); + } + + @Override + public void compensate(long warehouseId, Transition transition) { + client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", + transition.palletId(), transition.operationKey()) + .header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId)) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode()); + }).toBodilessEntity(); + } + + private record ResolveEnvelope(PalletReference data) { } + private record LocationEnvelope(LocationReference data) { } + private record TransitionEnvelope(Transition data) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java new file mode 100644 index 000000000..f00726b97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.palletops.gateway; + +public interface PalletPhysicalGateway { + PalletReference resolve(String palletCode); + LocationReference resolveLocation(long warehouseId, long locationId); + Transition apply(long warehouseId, PalletReference pallet, String targetStatus, long targetLocationId, + String operationKey, String reason); + void compensate(long warehouseId, Transition transition); + + record PalletReference(long palletId, String palletCode, long warehouseId, long locationId, + String status, String fbaWarehouseCode, int version) { } + record LocationReference(long locationId, String locationCode, String locationType, String status) { } + record Transition(String operationKey, long palletId, String palletCode, long warehouseId, + String beforeStatus, String afterStatus, long beforeLocationId, long afterLocationId, + int beforeVersion, int afterVersion, String operationStatus, boolean replayed) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java new file mode 100644 index 000000000..91be448d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.palletops.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaPalletOperationMapper extends BaseMapper { + String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + + "b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," + + "p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," + + "p.available_packages,p.hold_packages,p.allocated_packages,p.picked_packages,p.receiving_packages,p.version " + + "FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id "; + + @Select("SELECT * FROM ll_pda_pallet_operation WHERE operator_key=#{operatorKey} " + + "AND idempotency_key=#{idempotencyKey} LIMIT 1") + PdaPalletOperation findOperation(@Param("operatorKey") String operatorKey, + @Param("idempotencyKey") String idempotencyKey); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY b.customer_id,b.inbound_code,p.id") + List content(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.id=#{positionId} " + + "LIMIT 1 FOR UPDATE") + PalletInventoryRow lockPosition(@Param("warehouseId") String warehouseId, + @Param("positionId") long positionId); + + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") + InventoryPosition lockTarget(@Param("customerId") String customerId, @Param("balanceId") long balanceId, + @Param("palletId") long palletId, @Param("batchNo") String batchNo); + + @Select("SELECT COALESCE(SUM(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages),0) " + + "FROM ll_inventory_position WHERE pallet_id=#{palletId} AND overseas_warehouse_id=#{warehouseId}") + long palletPackages(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java new file mode 100644 index 000000000..98aa0084c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.palletops.model; + +import lombok.Data; + +@Data +public class PalletInventoryRow { + private String customerId; + private String customerName; + private Long balanceId; + private Long positionId; + private String inboundCode; + private String skuCode; + private String productNameZh; + private String productNameEn; + private String destinationWarehouseCode; + private Long palletId; + private String palletNo; + private String batchNo; + private Long warehouseLocationId; + private String warehouseLocationCode; + private Long availablePackages; + private Long holdPackages; + private Long allocatedPackages; + private Long pickedPackages; + private Long receivingPackages; + private Long version; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java new file mode 100644 index 000000000..3bc77d10c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -0,0 +1,506 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +public class PdaPalletOperationService { + @Resource private PdaPalletOperationMapper mapper; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + private TransactionTemplate transaction; + private TransactionTemplate newTransaction; + + @Resource + void configureTransactions(PlatformTransactionManager manager) { + transaction = new TransactionTemplate(manager); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + newTransaction = new TransactionTemplate(manager, definition); + } + + public PalletOperationResponses.PalletContent resolve(PdaWarehouseIdentity identity, + PalletOperationRequests.Scan request) { + PalletPhysicalGateway.PalletReference pallet = requirePallet(identity.overseasWarehouseId(), request.code()); + return content(identity.overseasWarehouseId(), pallet); + } + + public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Assemble request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.target(), request.sources()); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + Map sources = new LinkedHashMap<>(); + request.sources().forEach(value -> sources.put(code(value.palletCode()), pallets.get(code(value.palletCode())))); + List moves = plannedLines(identity.overseasWarehouseId(), request.lines(), sources, + target, null); + requireCommonDestination(identity.overseasWarehouseId(), moves, List.of(target)); + return execute(identity, "PALLET_ASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity, + PalletOperationRequests.Disassemble request) { + LinkedHashMap expectations = new LinkedHashMap<>(); + expectations.put(code(request.source().palletCode()), request.source()); + request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); + Map pallets = resolvePallets(identity.overseasWarehouseId(), + expectations.values()); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List moves = new ArrayList<>(); + for (PalletOperationRequests.TargetMove targetMove : request.targets()) { + PalletPhysicalGateway.PalletReference target = pallets.get(code(targetMove.target().palletCode())); + moves.addAll(plannedLines(identity.overseasWarehouseId(), targetMove.lines(), Map.of( + code(request.source().palletCode()), source), target, source)); + } + requireUniqueLines(moves); + List targets = request.targets().stream() + .map(value -> pallets.get(code(value.target().palletCode()))).toList(); + requireCommonDestination(identity.overseasWarehouseId(), moves, targets); + return execute(identity, "PALLET_DISASSEMBLE", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, + targets.stream().map(PalletPhysicalGateway.PalletReference::palletId).collect(java.util.stream.Collectors.toSet()), + false)); + } + + public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, + PalletOperationRequests.Swap request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + request.source(), List.of(request.target())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); + if (!mapper.content(String.valueOf(identity.overseasWarehouseId()), target.palletId()).isEmpty()) { + throw conflict("换托目标托盘必须为空"); + } + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(identity.overseasWarehouseId(), target.locationId())); + }).toList(); + return execute(identity, "PALLET_SWAP", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(target.palletId()), false)); + } + + public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity, + PalletOperationRequests.Unbind request) { + Map pallets = resolvePallets(identity.overseasWarehouseId(), + List.of(request.source())); + PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); + List rows = nonEmptyContent(identity.overseasWarehouseId(), source.palletId()); + List moves = rows.stream().map(row -> { + requireMovable(row, value(row.getAvailablePackages())); + return new PlannedMove(row.getPositionId(), value(row.getAvailablePackages()), value(row.getVersion()), + source.palletId(), source.palletCode(), 0L, "UNBOUND", source.locationId(), + locationCode(identity.overseasWarehouseId(), source.locationId())); + }).toList(); + return execute(identity, "PALLET_UNBIND", request.idempotencyKey(), request, request.reason(), + plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); + } + + private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key, + Object request, String reason, Plan plan) { + String normalizedKey = required(key, "幂等键不能为空"); + String requestHash = hash(request); + PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash); + if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); + + List transitions = new ArrayList<>(); + try { + for (PalletChange change : plan.changes()) { + transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, + change.pallet().palletId()), reason)); + } + saveTransitions(operation.getId(), transitions); + PalletOperationResponses.Operation result = transaction.execute(status -> + mutate(identity, operation, plan, normalizedKey, requestHash, reason)); + if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); + return result; + } catch (RuntimeException failure) { + boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + fail(operation.getId(), failure, compensated); + throw failure; + } + } + + private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, + Plan plan, String key, String requestHash, String reason) { + Set seen = new HashSet<>(); + LinkedHashSet customers = new LinkedHashSet<>(); + for (PlannedMove move : plan.moves()) { + if (!seen.add(move.positionId())) throw conflict("同一库存行不能在一次托盘作业中重复移动"); + PalletInventoryRow locked = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), + move.positionId()); + if (locked == null || !Objects.equals(locked.getPalletId(), move.sourcePalletId())) { + throw conflict("库存所在托盘已变化,请重新扫码"); + } + if (value(locked.getVersion()) != move.expectedVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(locked, move.packages()); + customers.add(locked.getCustomerId()); + moveInventory(identity, operation, locked, move, key, reason); + } + for (PalletChange change : plan.changes()) { + long actual = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), change.pallet().palletId()); + boolean shouldBeEmpty = "IDLE".equals(change.targetStatus()); + if (shouldBeEmpty != (actual == 0)) { + throw conflict("库存与实物托盘状态不一致,作业已回滚"); + } + } + + List contents = plan.pallets().values().stream() + .sorted(Comparator.comparingLong(PalletPhysicalGateway.PalletReference::palletId)) + .map(pallet -> content(identity.overseasWarehouseId(), physical.resolve(pallet.palletCode()))) + .toList(); + PalletOperationResponses.Operation result = new PalletOperationResponses.Operation(operation.getId(), + operation.getOperationType(), key, "COMPLETED", false, LocalDateTime.now(), contents); + operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setLastError(null); + operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); + history.record(identity.overseasWarehouseId(), customers.size() == 1 ? customers.iterator().next() : null, + identity.operatorKey(), operation.getOperationType(), businessKey(plan), "COMPLETED", key, requestHash, + "托盘作业完成,移动库存行 " + plan.moves().size() + " 条"); + return result; + } + + private void moveInventory(PdaWarehouseIdentity identity, PdaPalletOperation operation, + PalletInventoryRow sourceRow, PlannedMove move, String key, String reason) { + InventoryPosition source = positions.selectById(sourceRow.getPositionId()); + if (source == null) throw conflict("库存行不存在"); + long sourceBefore = value(source.getAvailablePackages()); + source.setAvailablePackages(sourceBefore - move.packages()); + source.setStatus(positionPackages(source) == 0 ? "EMPTY" : "OCCUPIED"); + source.setVersion(value(source.getVersion()) + 1); source.setUpdatedAt(LocalDateTime.now()); + positions.updateById(source); + + InventoryPosition target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), + move.targetPalletId(), source.getBatchNo()); + if (target == null) { + target = new InventoryPosition(); target.setId(IdWorker.getId()); + target.setCustomerId(source.getCustomerId()); target.setBalanceId(source.getBalanceId()); + target.setOverseasWarehouseId(source.getOverseasWarehouseId()); + target.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); + target.setPalletId(move.targetPalletId()); target.setPalletNo(move.targetPalletCode()); + target.setBatchNo(source.getBatchNo()); target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setAvailablePackages(0L); target.setHoldPackages(0L); target.setAllocatedPackages(0L); + target.setPickedPackages(0L); target.setReceivingPackages(0L); target.setStatus("OCCUPIED"); + target.setVersion(0L); target.setCreatedAt(LocalDateTime.now()); target.setUpdatedAt(target.getCreatedAt()); + try { positions.insert(target); } + catch (DuplicateKeyException duplicate) { + target = mapper.lockTarget(source.getCustomerId(), source.getBalanceId(), move.targetPalletId(), source.getBatchNo()); + if (target == null) throw duplicate; + } + } + long targetBefore = value(target.getAvailablePackages()); + target.setAvailablePackages(targetBefore + move.packages()); target.setStatus("OCCUPIED"); + target.setWarehouseLocationId(move.targetLocationId()); + target.setWarehouseLocationCode(move.targetLocationCode()); + target.setVersion(value(target.getVersion()) + 1); target.setUpdatedAt(LocalDateTime.now()); + positions.updateById(target); + + InventoryBalance balance = balances.selectById(source.getBalanceId()); + if (balance == null) throw new IllegalStateException("库存余额不存在"); + ledger(identity, operation, balance, source, key + ":" + source.getId() + ":OUT", + sourceBefore, -move.packages(), sourceBefore - move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + ledger(identity, operation, balance, target, key + ":" + source.getId() + ":IN", + targetBefore, move.packages(), targetBefore + move.packages(), move.sourcePalletCode(), move.targetPalletCode(), reason); + } + + private void ledger(PdaWarehouseIdentity identity, PdaPalletOperation operation, InventoryBalance balance, + InventoryPosition position, String idempotencyKey, long before, long delta, long after, + String sourcePallet, String targetPallet, String reason) { + InventoryLedger ledger = new InventoryLedger(); ledger.setId(IdWorker.getId()); + ledger.setCustomerId(balance.getCustomerId()); ledger.setBalanceId(balance.getId()); + ledger.setPositionId(position.getId()); ledger.setOverseasWarehouseId(balance.getOverseasWarehouseId()); + ledger.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); + ledger.setInboundOrderId(balance.getInboundOrderId()); ledger.setPalletId(position.getPalletId()); + ledger.setPalletNo(position.getPalletNo()); ledger.setBatchNo(position.getBatchNo()); + ledger.setSkuCode(balance.getSkuCode()); ledger.setWarehouseLocationCode(position.getWarehouseLocationCode()); + ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId()); + ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode()); + ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey); + ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet); + ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after); + ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason)); + ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); + } + + private Plan plan(long warehouseId, Map pallets, + List moves, Set targets, boolean unbind) { + Map movedBySource = new HashMap<>(); + moves.forEach(move -> movedBySource.merge(move.sourcePalletId(), move.packages(), Long::sum)); + List changes = new ArrayList<>(); + for (PalletPhysicalGateway.PalletReference pallet : pallets.values()) { + long total = mapper.palletPackages(String.valueOf(warehouseId), pallet.palletId()); + long moved = movedBySource.getOrDefault(pallet.palletId(), 0L); + String targetStatus = targets.contains(pallet.palletId()) ? "OCCUPIED" + : moved > 0 && moved == total ? "IDLE" : pallet.status(); + if ("SCRAPPED".equals(targetStatus)) throw conflict("已报废托盘不能执行作业"); + changes.add(new PalletChange(pallet, targetStatus, pallet.locationId())); + } + if (unbind && changes.stream().noneMatch(change -> "IDLE".equals(change.targetStatus()))) { + throw conflict("解绑必须清空来源托盘"); + } + changes.sort(Comparator.comparingLong(change -> change.pallet().palletId())); + return new Plan(pallets, moves, changes); + } + + private List plannedLines(long warehouseId, List lines, + Map sources, + PalletPhysicalGateway.PalletReference target, + PalletPhysicalGateway.PalletReference requiredSource) { + Map available = new HashMap<>(); + for (PalletPhysicalGateway.PalletReference source : sources.values()) { + for (PalletInventoryRow row : mapper.content(String.valueOf(warehouseId), source.palletId())) { + available.put(row.getPositionId(), row); + } + } + List result = new ArrayList<>(); + for (PalletOperationRequests.MoveLine line : lines) { + PalletInventoryRow row = available.get(line.positionId()); + if (row == null || requiredSource != null && !Objects.equals(row.getPalletId(), requiredSource.palletId())) { + throw conflict("库存行不属于已扫描的来源托盘"); + } + if (value(row.getVersion()) != line.expectedPositionVersion()) throw conflict("库存版本已变化,请重新扫码"); + requireMovable(row, line.packages()); + result.add(new PlannedMove(row.getPositionId(), line.packages(), line.expectedPositionVersion(), + row.getPalletId(), row.getPalletNo(), target.palletId(), target.palletCode(), target.locationId(), + locationCode(warehouseId, target.locationId()))); + } + requireUniqueLines(result); + return result; + } + + private void requireCommonDestination(long warehouseId, List moves, + List targets) { + LinkedHashSet destinations = new LinkedHashSet<>(); + for (PlannedMove move : moves) { + PalletInventoryRow row = mapper.content(String.valueOf(warehouseId), move.sourcePalletId()).stream() + .filter(value -> Objects.equals(value.getPositionId(), move.positionId())).findFirst() + .orElseThrow(() -> conflict("库存行不存在")); + destinations.add(text(row.getDestinationWarehouseCode())); + } + for (PalletPhysicalGateway.PalletReference target : targets) { + mapper.content(String.valueOf(warehouseId), target.palletId()).stream() + .map(PalletInventoryRow::getDestinationWarehouseCode).map(PdaPalletOperationService::text) + .forEach(destinations::add); + } + if (destinations.size() > 1) throw conflict("不同目的仓货物不能组到同一托盘"); + } + + private Map resolvePallets(long warehouseId, + PalletOperationRequests.PalletExpectation first, List rest) { + List values = new ArrayList<>(); values.add(first); values.addAll(rest); + return resolvePallets(warehouseId, values); + } + private Map resolvePallets(long warehouseId, + java.util.Collection values) { + LinkedHashMap result = new LinkedHashMap<>(); + for (PalletOperationRequests.PalletExpectation expected : values) { + String code = code(expected.palletCode()); + if (result.containsKey(code)) throw badRequest("一次作业不能重复提交同一托盘"); + PalletPhysicalGateway.PalletReference pallet = requirePallet(warehouseId, code); + if (pallet.version() != expected.expectedVersion()) throw conflict("托盘版本已变化,请重新扫码"); + result.put(code, pallet); + } + return result; + } + + private PalletPhysicalGateway.PalletReference requirePallet(long warehouseId, String code) { + PalletPhysicalGateway.PalletReference pallet = physical.resolve(code(code)); + if (pallet.warehouseId() != warehouseId) throw conflict("托盘不属于 Token 冻结的海外仓"); + if ("SCRAPPED".equals(pallet.status())) throw conflict("已报废托盘不能执行作业"); + return pallet; + } + + private List nonEmptyContent(long warehouseId, long palletId) { + List values = mapper.content(String.valueOf(warehouseId), palletId); + if (values.isEmpty()) throw conflict("来源托盘没有可操作库存"); + return values; + } + + private PalletOperationResponses.PalletContent content(long warehouseId, + PalletPhysicalGateway.PalletReference pallet) { + List rows = mapper.content(String.valueOf(warehouseId), pallet.palletId()); + LinkedHashSet destinations = new LinkedHashSet<>(); + List inventory = rows.stream().map(row -> { + destinations.add(text(row.getDestinationWarehouseCode())); + return new PalletOperationResponses.InventoryLine(row.getCustomerId(), row.getCustomerName(), + value(row.getBalanceId()), value(row.getPositionId()), row.getInboundCode(), row.getSkuCode(), + first(row.getProductNameZh(), row.getProductNameEn()), value(row.getAvailablePackages()), + value(row.getHoldPackages()), value(row.getAllocatedPackages()), value(row.getPickedPackages()), + value(row.getReceivingPackages()), value(row.getVersion())); + }).toList(); + long total = rows.stream().mapToLong(PdaPalletOperationService::rowPackages).sum(); + String locationCode = locationCode(warehouseId, pallet.locationId()); + return new PalletOperationResponses.PalletContent(pallet.palletId(), pallet.palletCode(), pallet.status(), + pallet.version(), pallet.locationId(), locationCode, destinations.size() == 1 ? destinations.iterator().next() : null, + total, inventory); + } + + private PdaPalletOperation begin(PdaWarehouseIdentity identity, String type, String key, String requestHash) { + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), key); + if (existing != null) return validateExisting(existing, type, requestHash); + try { + return newTransaction.execute(status -> { + PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(IdWorker.getId()); + operation.setOverseasWarehouseId(identity.overseasWarehouseId()); + operation.setOperatorKey(identity.operatorKey()); operation.setIdempotencyKey(key); + operation.setOperationType(type); operation.setRequestHash(requestHash); operation.setStatus("PROCESSING"); + operation.setCreatedAt(LocalDateTime.now()); operation.setUpdatedAt(operation.getCreatedAt()); + mapper.insert(operation); return operation; + }); + } catch (DuplicateKeyException duplicate) { + PdaPalletOperation winner = mapper.findOperation(identity.operatorKey(), key); + if (winner != null) return validateExisting(winner, type, requestHash); + throw duplicate; + } + } + + private PdaPalletOperation validateExisting(PdaPalletOperation value, String type, String requestHash) { + if (!type.equals(value.getOperationType()) || !requestHash.equals(value.getRequestHash())) { + throw conflict("幂等键已被不同托盘作业使用"); + } + if (!"COMPLETED".equals(value.getStatus())) { + throw conflict("同一托盘作业正在处理或需要人工恢复"); + } + return value; + } + + private void saveTransitions(long operationId, List transitions) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private void fail(long operationId, RuntimeException failure, boolean compensated) { + newTransaction.executeWithoutResult(status -> { + PdaPalletOperation operation = mapper.selectById(operationId); + if (operation == null || "COMPLETED".equals(operation.getStatus())) return; + operation.setStatus(compensated ? "FAILED_COMPENSATED" : "COMPENSATION_REQUIRED"); + operation.setLastError(limit(failure.getMessage(), 2000)); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + }); + } + private boolean compensate(long warehouseId, List transitions) { + boolean success = true; + for (int i = transitions.size() - 1; i >= 0; i--) { + try { physical.compensate(warehouseId, transitions.get(i)); } + catch (RuntimeException ignored) { success = false; } + } + return success; + } + + private PalletOperationResponses.Operation read(PdaPalletOperation operation) { + try { return objectMapper.readValue(operation.getResultJson(), PalletOperationResponses.Operation.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取托盘作业幂等结果", exception); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法序列化托盘作业", exception); } + } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String transitionKey(String operator, String key, long palletId) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest((operator + "|" + key + "|" + palletId).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private String locationCode(long warehouseId, long locationId) { + PalletPhysicalGateway.LocationReference location = physical.resolveLocation(warehouseId, locationId); + if (location == null || location.locationId() != locationId || "INACTIVE".equals(location.status())) { + throw conflict("托盘库位不存在或已停用"); + } + return location.locationCode(); + } + private static void requireMovable(PalletInventoryRow row, long packages) { + if (packages <= 0 || value(row.getAvailablePackages()) < packages) throw conflict("可用库存箱数不足"); + if (value(row.getHoldPackages()) > 0 || value(row.getAllocatedPackages()) > 0 + || value(row.getPickedPackages()) > 0 || value(row.getReceivingPackages()) > 0) { + throw conflict("冻结、已分配、已拣或收货中的库存不能执行托盘作业"); + } + } + private static void requireUniqueLines(List moves) { + Set ids = new HashSet<>(); + if (moves.stream().anyMatch(move -> !ids.add(move.positionId()))) throw badRequest("库存行不能重复提交"); + } + private static long rowPackages(PalletInventoryRow row) { + return value(row.getAvailablePackages()) + value(row.getHoldPackages()) + value(row.getAllocatedPackages()) + + value(row.getPickedPackages()) + value(row.getReceivingPackages()); + } + private static long positionPackages(InventoryPosition value) { + return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + value(value.getAllocatedPackages()) + + value(value.getPickedPackages()) + value(value.getReceivingPackages()); + } + private static String businessKey(Plan plan) { + return plan.pallets().values().stream().map(PalletPhysicalGateway.PalletReference::palletCode) + .sorted().collect(java.util.stream.Collectors.joining("->")); + } + private static String required(String value, String message) { String result = trim(value); if (result == null) throw badRequest(message); return result; } + private static String code(String value) { return required(value, "托盘码不能为空").toUpperCase(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String text(String value) { return value == null ? "" : value.trim(); } + private static String first(String first, String second) { String value = trim(first); return value == null ? trim(second) : value; } + private static String limit(String value, int size) { if (value == null) return null; return value.length() <= size ? value : value.substring(0, size); } + private static long value(Long value) { return value == null ? 0L : value; } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + + private record PlannedMove(long positionId, long packages, long expectedVersion, + long sourcePalletId, String sourcePalletCode, + long targetPalletId, String targetPalletCode, long targetLocationId, + String targetLocationCode) { } + private record PalletChange(PalletPhysicalGateway.PalletReference pallet, String targetStatus, + long targetLocationId) { } + private record Plan(Map pallets, + List moves, List changes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java new file mode 100644 index 000000000..6bfdf88b7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.pda.move.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.service.PdaInventoryMoveService; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +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.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/inventory-moves") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaInventoryMoveController { + @Resource private PdaInventoryMoveService service; + + @PostMapping + public ApiResponse move(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaInventoryMoveRequest request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.move(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java new file mode 100644 index 000000000..0e79b961d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.pda.move.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public record PdaInventoryMoveRequest( + @NotBlank @Size(max = 128) String sourcePalletCode, + @NotBlank @Size(max = 128) String targetLocationCode, + @PositiveOrZero int expectedPalletVersion, + @NotBlank @Size(max = 128) String idempotencyKey, + @NotBlank @Size(max = 500) String reason +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java new file mode 100644 index 000000000..40f55affc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.pda.move.dto; + +import java.time.LocalDateTime; + +public record PdaInventoryMoveResponse( + String moveNo, + long palletId, + String palletCode, + long sourceLocationId, + String sourceLocationCode, + long targetLocationId, + String targetLocationCode, + int customerCount, + int positionCount, + long packageCount, + int palletVersion, + String status, + LocalDateTime completedAt, + boolean replayed +) { + public PdaInventoryMoveResponse asReplay() { + return replayed ? this : new PdaInventoryMoveResponse(moveNo, palletId, palletCode, sourceLocationId, + sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, + packageCount, palletVersion, status, completedAt, true); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java new file mode 100644 index 000000000..c57580eaa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.move.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_pda_inventory_move") +public class PdaInventoryMove { + @TableId(type = IdType.INPUT) private Long id; + private String moveNo; + private Long overseasWarehouseId; + private Long palletId; + private String palletCode; + private Long sourceLocationId; + private String sourceLocationCode; + private Long targetLocationId; + private String targetLocationCode; + private Integer customerCount; + private Integer positionCount; + private Long packageCount; + private Integer palletVersionBefore; + private Integer palletVersionAfter; + private String operatorKey; + private String reason; + private String idempotencyKey; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime completedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java new file mode 100644 index 000000000..4347aa101 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.pda.move.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface PdaInventoryMoveMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_inventory_move WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaInventoryMove find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_id=#{palletId} AND " + + "(available_packages+hold_packages+allocated_packages+picked_packages+receiving_packages)>0 " + + "ORDER BY customer_id,id FOR UPDATE") + List lockPositions(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java new file mode 100644 index 000000000..03be9225d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -0,0 +1,192 @@ +package com.baiyue.lastleg.pda.move.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveResponse; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +@Service +public class PdaInventoryMoveService { + @Resource private PdaInventoryMoveMapper moves; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PalletPhysicalGateway physical; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + @Transactional + public PdaInventoryMoveResponse move(long warehouseId, String operatorKey, PdaInventoryMoveRequest request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaInventoryMove existing = moves.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, requestHash); + + var pallet = warehouse.resolvePallet(required(request.sourcePalletCode(), "Source pallet code is required")); + if (pallet.warehouseId() != warehouseId) throw conflict("Source pallet belongs to another overseas warehouse"); + var target = warehouse.resolveLocation(warehouseId, + required(request.targetLocationCode(), "Target location code is required")); + if (!Set.of("ACTIVE", "IDLE", "OCCUPIED").contains(target.status())) { + throw conflict("Target location is not available"); + } + boolean recoveringRemoteSuccess = pallet.locationId() == target.locationId() + && pallet.version() == request.expectedPalletVersion() + 1; + if (!recoveringRemoteSuccess && pallet.version() != request.expectedPalletVersion()) { + throw conflict("Source pallet version changed; rescan it"); + } + + List inventory = moves.lockPositions(String.valueOf(warehouseId), pallet.palletId()); + if (inventory.isEmpty()) throw conflict("Source pallet has no movable inventory"); + Set inventoryLocations = new HashSet<>(); + inventory.forEach(position -> inventoryLocations.add(position.getWarehouseLocationId())); + if (inventoryLocations.contains(null) || inventoryLocations.size() != 1) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + long sourceLocationId = inventoryLocations.iterator().next(); + if (!recoveringRemoteSuccess && sourceLocationId != pallet.locationId()) { + throw conflict("Inventory and warehouse pallet locations are inconsistent; reconcile before moving"); + } + if (sourceLocationId == target.locationId()) throw badRequest("Source and target locations must differ"); + var source = warehouse.resolveLocation(warehouseId, sourceLocationId); + long packages = inventory.stream().mapToLong(PdaInventoryMoveService::physical).sum(); + Set customers = new HashSet<>(); + inventory.forEach(position -> customers.add(position.getCustomerId())); + + long moveId = IdWorker.getId(); + PdaInventoryMove operation = new PdaInventoryMove(); + operation.setId(moveId); operation.setMoveNo("MV-" + Long.toUnsignedString(moveId)); + operation.setOverseasWarehouseId(warehouseId); operation.setPalletId(pallet.palletId()); + operation.setPalletCode(pallet.palletNo()); operation.setSourceLocationId(source.locationId()); + operation.setSourceLocationCode(source.locationCode()); operation.setTargetLocationId(target.locationId()); + operation.setTargetLocationCode(target.locationCode()); operation.setCustomerCount(customers.size()); + operation.setPositionCount(inventory.size()); operation.setPackageCount(packages); + operation.setPalletVersionBefore(pallet.version()); operation.setOperatorKey(operatorKey); + operation.setReason(request.reason().trim()); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); + operation.setStatus("PROCESSING"); operation.setCreatedAt(LocalDateTime.now()); + try { moves.insert(operation); } + catch (DuplicateKeyException duplicate) { + PdaInventoryMove winner = moves.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, requestHash); + } + + String transitionKey = transitionKey(operatorKey, key, pallet.palletId()); + var transitionSource = new PalletPhysicalGateway.PalletReference(pallet.palletId(), pallet.palletNo(), + warehouseId, source.locationId(), "OCCUPIED", pallet.fbaWarehouseCode(), request.expectedPalletVersion()); + var relocated = physical.apply(warehouseId, transitionSource, "OCCUPIED", target.locationId(), + transitionKey, request.reason()); + if (relocated.afterLocationId() != target.locationId() + || !"APPLIED".equals(relocated.operationStatus())) { + throw new IllegalStateException("Warehouse relocation returned another target location"); + } + LocalDateTime completedAt = LocalDateTime.now(); + for (InventoryPosition position : inventory) { + InventoryBalance balance = balances.selectById(position.getBalanceId()); + if (balance == null || !position.getCustomerId().equals(balance.getCustomerId()) + || !String.valueOf(warehouseId).equals(balance.getOverseasWarehouseId())) { + throw new IllegalStateException("Inventory balance scope is inconsistent during relocation"); + } + position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode()); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt); + positions.updateById(position); + ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(), + target.locationCode(), completedAt); + } + operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED"); + operation.setCompletedAt(completedAt); + PdaInventoryMoveResponse response = response(operation, false); + operation.setResultJson(write(response)); moves.updateById(operation); + history.record(warehouseId, null, operatorKey, "INVENTORY_MOVE", operation.getMoveNo(), "COMPLETED", + "INVENTORY_MOVE:" + key, requestHash, + pallet.palletNo() + ":" + source.locationCode() + " → " + target.locationCode() + + "," + packages + " 箱"); + return response; + } + + private PdaInventoryMoveResponse replay(PdaInventoryMove operation, String requestHash) { + if (!requestHash.equals(operation.getRequestHash())) { + throw conflict("Idempotency key was reused with a different inventory move"); + } + if (!"COMPLETED".equals(operation.getStatus()) || operation.getResultJson() == null) { + throw conflict("Inventory move is still processing"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaInventoryMoveResponse.class).asReplay(); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read inventory move replay", error); } + } + + private PdaInventoryMoveResponse response(PdaInventoryMove value, boolean replayed) { + return new PdaInventoryMoveResponse(value.getMoveNo(), value.getPalletId(), value.getPalletCode(), + value.getSourceLocationId(), value.getSourceLocationCode(), value.getTargetLocationId(), + value.getTargetLocationCode(), value.getCustomerCount(), value.getPositionCount(), + value.getPackageCount(), value.getPalletVersionAfter(), value.getStatus(), value.getCompletedAt(), replayed); + } + + private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance, + String operator, String key, String reason, String source, String target, + LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId()); + row.setBalanceId(balance.getId()); row.setPositionId(position.getId()); + row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE"); + row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId()); + row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position); + row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages); + row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now); + ledgers.insert(row); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static String transitionKey(String operator, String key, long palletId) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(("INVENTORY_MOVE|" + operator + "|" + key + "|" + palletId) + .getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize inventory move", error); } + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static String required(String value, String message) { if (value == null || value.isBlank()) throw badRequest(message); return value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java new file mode 100644 index 000000000..fb103115a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.pda.stocktake.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.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.service.PdaStocktakeService; +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.PathVariable; +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/stocktakes") +@PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')") +public class PdaStocktakeController { + @Resource private PdaStocktakeService service; + + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, @Valid PdaStocktakeRequests.TaskQuery query) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.page(identity.overseasWarehouseId(), query)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody PdaStocktakeRequests.Create request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.create(identity.overseasWarehouseId(), identity.operatorKey(), request)); + } + + @GetMapping("/{taskNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.detail(identity.overseasWarehouseId(), taskNo)); + } + + @PostMapping("/{taskNo}/counts") + public ApiResponse count(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Count request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.count(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } + + @PostMapping("/{taskNo}/submit") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String taskNo, @Valid @RequestBody PdaStocktakeRequests.Submit request) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.submit(identity.overseasWarehouseId(), identity.operatorKey(), taskNo, request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java new file mode 100644 index 000000000..bfba023cd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PdaStocktakeRequests { + private PdaStocktakeRequests() { } + + public record TaskQuery(@Min(1) long page, @Min(1) @Max(100) long size, String status) { + public TaskQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record Create(@NotBlank @Size(max = 16) String scopeType, + @NotBlank @Size(max = 128) String scopeCode, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Count(@NotNull Long positionId, @PositiveOrZero long actualPackages, + @PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 128) String idempotencyKey) { } + + public record Submit(@PositiveOrZero long expectedVersion, + @NotBlank @Size(max = 500) String reason, + @NotBlank @Size(max = 128) String idempotencyKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java new file mode 100644 index 000000000..ce72041aa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaStocktakeResponses { + private PdaStocktakeResponses() { } + + public record Summary(String taskNo, String scopeType, String scopeCode, String status, + int positionCount, int countedCount, int differenceCount, long version, + LocalDateTime createdAt, LocalDateTime submittedAt) { } + + public record Item(long itemId, String customerId, long balanceId, long positionId, + String skuCode, String productName, String inboundCode, + long palletId, String palletCode, long locationId, String locationCode, + long bookPackages, Long actualPackages, Long differencePackages, + String status, long positionVersion, long version) { } + + public record Detail(Summary task, List items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java new file mode 100644 index 000000000..a0246bf33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_action") +public class PdaStocktakeAction { + @TableId(type = IdType.INPUT) private Long id; + private Long overseasWarehouseId; + private String operatorKey; + private String idempotencyKey; + private String actionType; + private Long taskId; + private String requestHash; + private String status; + private String resultJson; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java new file mode 100644 index 000000000..0473f5343 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_item") +public class PdaStocktakeItem { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long actualPackages; + private Long differencePackages; + private String status; + private Long positionVersion; + private String countedBy; + private Long version; + private LocalDateTime countedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java new file mode 100644 index 000000000..733b8ac43 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.pda.stocktake.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_pda_stocktake_task") +public class PdaStocktakeTask { + @TableId(type = IdType.INPUT) private Long id; + private String taskNo; + private Long overseasWarehouseId; + private String scopeType; + private Long scopeResourceId; + private String scopeCode; + private String status; + private Integer positionCount; + private Integer countedCount; + private Integer differenceCount; + private String createdBy; + private String submittedBy; + private String submitReason; + private String idempotencyKey; + private String requestHash; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime submittedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java new file mode 100644 index 000000000..3e42d42d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeActionMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_action WHERE overseas_warehouse_id=#{warehouseId} " + + "AND operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeAction find(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java new file mode 100644 index 000000000..e2ab71e35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaStocktakeInventoryMapper { + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryPosition lockPosition(@Param("positionId") long positionId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); + + @Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} AND customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} LIMIT 1 FOR UPDATE") + InventoryBalance lockBalance(@Param("balanceId") long balanceId, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java new file mode 100644 index 000000000..e74e61729 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeItemMapper extends BaseMapper { + String CANDIDATE = "SELECT p.customer_id,b.id balance_id,p.id position_id,b.sku_code," + + "COALESCE(NULLIF(b.product_name_zh,''),b.product_name_en) product_name,b.inbound_code," + + "p.pallet_id,p.pallet_no pallet_code,p.warehouse_location_id location_id," + + "p.warehouse_location_code location_code," + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages) book_packages," + + "p.version position_version FROM ll_inventory_position p JOIN ll_inventory_balance b " + + "ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.overseas_warehouse_id=#{warehouseId} "; + + @Select(CANDIDATE + "AND p.pallet_id=#{palletId} AND " + + "(p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.id") + List candidatesByPallet(@Param("warehouseId") String warehouseId, + @Param("palletId") long palletId); + + @Select(CANDIDATE + "AND (p.warehouse_location_id=#{locationId} OR p.warehouse_location_code=#{locationCode}) " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "ORDER BY p.customer_id,p.pallet_no,p.id") + List candidatesByLocation(@Param("warehouseId") String warehouseId, + @Param("locationId") long locationId, + @Param("locationCode") String locationCode); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY location_code,pallet_code,position_id") + List list(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} ORDER BY id FOR UPDATE") + List lockAll(@Param("taskId") long taskId); + + @Select("SELECT * FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND position_id=#{positionId} LIMIT 1 FOR UPDATE") + PdaStocktakeItem lockItem(@Param("taskId") long taskId, @Param("positionId") long positionId); + + @Update("UPDATE ll_pda_stocktake_item SET actual_packages=#{actualPackages}," + + "difference_packages=#{differencePackages},status=#{status},counted_by=#{operatorKey}," + + "counted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1 " + + "WHERE id=#{itemId} AND version=#{expectedVersion} AND status IN ('PENDING','COUNTED')") + int countItem(@Param("itemId") long itemId, @Param("expectedVersion") long expectedVersion, + @Param("actualPackages") long actualPackages, @Param("differencePackages") long differencePackages, + @Param("status") String status, @Param("operatorKey") String operatorKey); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} AND actual_packages IS NOT NULL") + int counted(@Param("taskId") long taskId); + + @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "AND actual_packages IS NOT NULL AND difference_packages<>0") + int differences(@Param("taskId") long taskId); + + @Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," + + "pallet_id,pallet_code,location_id,location_code,book_packages,actual_packages,difference_packages," + + "status,position_version,version FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + + "ORDER BY location_code,pallet_code,position_id") + List views(@Param("taskId") long taskId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java new file mode 100644 index 000000000..77ffae857 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.pda.stocktake.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +@Mapper +public interface PdaStocktakeTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND created_by=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaStocktakeTask findCreate(@Param("warehouseId") long warehouseId, + @Param("operatorKey") String operatorKey, @Param("key") String key); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1") + PdaStocktakeTask find(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("SELECT * FROM ll_pda_stocktake_task WHERE overseas_warehouse_id=#{warehouseId} " + + "AND task_no=#{taskNo} LIMIT 1 FOR UPDATE") + PdaStocktakeTask lock(@Param("warehouseId") long warehouseId, @Param("taskNo") String taskNo); + + @Select("") + List page(@Param("warehouseId") long warehouseId, + @Param("status") String status, + @Param("offset") long offset, @Param("limit") long limit); + + @Select("") + long count(@Param("warehouseId") long warehouseId, @Param("status") String status); + + @Update("UPDATE ll_pda_stocktake_task SET counted_count=#{countedCount},difference_count=#{differenceCount}," + + "version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{taskId} AND version=#{expectedVersion} " + + "AND status='IN_PROGRESS'") + int refreshCounts(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("countedCount") int countedCount, @Param("differenceCount") int differenceCount); + + @Update("UPDATE ll_pda_stocktake_task SET status='SUBMITTED',submitted_by=#{operatorKey}," + + "submit_reason=#{reason},submitted_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3)," + + "version=version+1 WHERE id=#{taskId} AND version=#{expectedVersion} AND status='IN_PROGRESS'") + int submit(@Param("taskId") long taskId, @Param("expectedVersion") long expectedVersion, + @Param("operatorKey") String operatorKey, @Param("reason") String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java new file mode 100644 index 000000000..41443f089 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.pda.stocktake.model; + +import lombok.Data; + +@Data +public class StocktakeCandidate { + private String customerId; + private Long balanceId; + private Long positionId; + private String skuCode; + private String productName; + private String inboundCode; + private Long palletId; + private String palletCode; + private Long locationId; + private String locationCode; + private Long bookPackages; + private Long positionVersion; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java new file mode 100644 index 000000000..101e03b74 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java @@ -0,0 +1,323 @@ +package com.baiyue.lastleg.pda.stocktake.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.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeResponses; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeAction; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +@Service +public class PdaStocktakeService { + @Resource private PdaStocktakeTaskMapper tasks; + @Resource private PdaStocktakeItemMapper items; + @Resource private PdaStocktakeActionMapper actions; + @Resource private PdaStocktakeInventoryMapper inventory; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private InventoryLedgerMapper ledgers; + @Resource private WarehouseResourceGateway warehouse; + @Resource private PdaWarehouseService history; + @Resource private ObjectMapper objectMapper; + + public PageResult page(long warehouseId, PdaStocktakeRequests.TaskQuery query) { + String status = status(query.status()); + long offset = (query.page() - 1) * query.size(); + List rows = tasks.page(warehouseId, status, offset, query.size()); + long total = tasks.count(warehouseId, status); + return new PageResult<>(rows, query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + public PdaStocktakeResponses.Detail detail(long warehouseId, String taskNo) { + PdaStocktakeTask task = requireTask(warehouseId, taskNo, false); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Detail create(long warehouseId, String operatorKey, + PdaStocktakeRequests.Create request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeTask replay = tasks.findCreate(warehouseId, operatorKey, key); + if (replay != null) { + if (!requestHash.equals(replay.getRequestHash())) throw conflict("Idempotency key was reused with another stocktake"); + return detail(replay); + } + + String scopeType = required(request.scopeType(), "Scope type is required").toUpperCase(); + String scopeCode = required(request.scopeCode(), "Scope code is required"); + long resourceId; + List candidates; + if ("PALLET".equals(scopeType)) { + var pallet = warehouse.resolvePallet(scopeCode); + if (pallet.warehouseId() != warehouseId) throw conflict("Pallet belongs to another overseas warehouse"); + resourceId = pallet.palletId(); + scopeCode = pallet.palletNo(); + candidates = items.candidatesByPallet(String.valueOf(warehouseId), resourceId); + } else if ("LOCATION".equals(scopeType)) { + var location = warehouse.resolveLocation(warehouseId, scopeCode); + if (!List.of("ACTIVE", "IDLE", "OCCUPIED").contains(location.status())) { + throw conflict("Stocktake location is not active"); + } + resourceId = location.locationId(); + scopeCode = location.locationCode(); + candidates = items.candidatesByLocation(String.valueOf(warehouseId), resourceId, scopeCode); + } else { + throw badRequest("scopeType must be LOCATION or PALLET"); + } + if (candidates.isEmpty()) throw conflict("No inventory is available in the scanned scope"); + + long taskId = IdWorker.getId(); + LocalDateTime now = LocalDateTime.now(); + PdaStocktakeTask task = new PdaStocktakeTask(); + task.setId(taskId); task.setTaskNo("CC-" + Long.toUnsignedString(taskId)); + task.setOverseasWarehouseId(warehouseId); task.setScopeType(scopeType); task.setScopeResourceId(resourceId); + task.setScopeCode(scopeCode); task.setStatus("IN_PROGRESS"); task.setPositionCount(candidates.size()); + task.setCountedCount(0); task.setDifferenceCount(0); task.setCreatedBy(operatorKey); + task.setIdempotencyKey(key); task.setRequestHash(requestHash); task.setVersion(0L); + task.setCreatedAt(now); task.setUpdatedAt(now); + try { + tasks.insert(task); + } catch (DuplicateKeyException duplicate) { + PdaStocktakeTask winner = tasks.findCreate(warehouseId, operatorKey, key); + if (winner == null || !requestHash.equals(winner.getRequestHash())) throw duplicate; + return detail(winner); + } + for (StocktakeCandidate candidate : candidates) { + PdaStocktakeItem item = new PdaStocktakeItem(); + item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setCustomerId(candidate.getCustomerId()); + item.setBalanceId(candidate.getBalanceId()); item.setPositionId(candidate.getPositionId()); + item.setSkuCode(candidate.getSkuCode()); item.setProductName(candidate.getProductName()); + item.setInboundCode(candidate.getInboundCode()); item.setPalletId(candidate.getPalletId()); + item.setPalletCode(candidate.getPalletCode()); item.setLocationId(candidate.getLocationId()); + item.setLocationCode(candidate.getLocationCode()); item.setBookPackages(value(candidate.getBookPackages())); + item.setStatus("PENDING"); item.setPositionVersion(value(candidate.getPositionVersion())); + item.setVersion(0L); item.setCreatedAt(now); item.setUpdatedAt(now); items.insert(item); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_CREATE", task.getTaskNo(), "IN_PROGRESS", + "STOCKTAKE_CREATE:" + key, requestHash, + scopeType + " " + scopeCode + ",共 " + candidates.size() + " 个库存位置"); + return detail(task); + } + + @Transactional + public PdaStocktakeResponses.Item count(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Count request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "COUNT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Item.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Submitted stocktake cannot be changed"); + PdaStocktakeItem item = items.lockItem(task.getId(), request.positionId()); + if (item == null) throw notFound("Stocktake position was not found in this task"); + long difference = request.actualPackages() - value(item.getBookPackages()); + if (items.countItem(item.getId(), request.expectedVersion(), request.actualPackages(), difference, + difference == 0 ? "MATCHED" : "DIFFERENCE", operatorKey) != 1) { + throw conflict("Stocktake item version changed; refresh and retry"); + } + int counted = items.counted(task.getId()); + int differences = items.differences(task.getId()); + if (tasks.refreshCounts(task.getId(), value(task.getVersion()), counted, differences) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + PdaStocktakeResponses.Item response = items.views(task.getId()).stream() + .filter(row -> row.positionId() == request.positionId()).findFirst().orElseThrow(); + complete(action, response); + return response; + } + + @Transactional + public PdaStocktakeResponses.Detail submit(long warehouseId, String operatorKey, String taskNo, + PdaStocktakeRequests.Submit request) { + String key = required(request.idempotencyKey(), "Idempotency key is required"); + String requestHash = hash(request); + PdaStocktakeAction action = beginAction(warehouseId, operatorKey, key, "SUBMIT", requestHash, taskNo); + if ("COMPLETED".equals(action.getStatus())) return read(action, PdaStocktakeResponses.Detail.class); + + PdaStocktakeTask task = requireTask(warehouseId, taskNo, true); + if (!"IN_PROGRESS".equals(task.getStatus())) throw conflict("Stocktake was already submitted"); + if (value(task.getVersion()) != request.expectedVersion()) throw conflict("Stocktake task version changed; refresh and retry"); + List taskItems = items.lockAll(task.getId()); + if (taskItems.isEmpty() || taskItems.stream().anyMatch(item -> item.getActualPackages() == null)) { + throw conflict("Every stocktake position must be counted before submission"); + } + + Map changedBalances = new HashMap<>(); + LocalDateTime now = LocalDateTime.now(); + for (PdaStocktakeItem item : taskItems) { + InventoryPosition position = inventory.lockPosition(item.getPositionId(), item.getCustomerId(), + String.valueOf(warehouseId)); + if (position == null || value(position.getVersion()) != value(item.getPositionVersion())) { + throw conflict("Inventory changed after counting; recreate the stocktake task"); + } + long before = physical(position); + if (before != value(item.getBookPackages())) { + throw conflict("Inventory package total changed after counting; recreate the stocktake task"); + } + long fixed = value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + + value(position.getPickedPackages()) + value(position.getReceivingPackages()); + if (value(item.getActualPackages()) < fixed) { + throw conflict("Actual packages cannot be lower than reserved or processing packages"); + } + long newAvailable = value(item.getActualPackages()) - fixed; + long delta = newAvailable - value(position.getAvailablePackages()); + InventoryBalance balance = changedBalances.get(item.getBalanceId()); + if (balance == null) { + balance = inventory.lockBalance(item.getBalanceId(), item.getCustomerId(), String.valueOf(warehouseId)); + if (balance == null) throw new IllegalStateException("Stocktake inventory balance is missing"); + changedBalances.put(item.getBalanceId(), balance); + } + long balanceAfter = value(balance.getAvailablePackages()) + delta; + if (balanceAfter < 0) throw conflict("Stocktake adjustment would make inventory negative"); + + position.setAvailablePackages(newAvailable); position.setStatus(physicalAfter(position, newAvailable) == 0 ? "EMPTY" : "OCCUPIED"); + position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(now); positions.updateById(position); + balance.setAvailablePackages(balanceAfter); balance.setVersion(value(balance.getVersion()) + 1); balance.setUpdatedAt(now); + + ledger(task, item, position, balance, operatorKey, key, request.reason(), before, delta, + value(item.getActualPackages()), now); + item.setStatus(delta == 0 ? "MATCHED" : "ADJUSTED"); item.setUpdatedAt(now); + item.setVersion(value(item.getVersion()) + 1); items.updateById(item); + } + changedBalances.values().forEach(balances::updateById); + if (tasks.submit(task.getId(), request.expectedVersion(), operatorKey, request.reason()) != 1) { + throw conflict("Stocktake task version changed; refresh and retry"); + } + history.record(warehouseId, null, operatorKey, "STOCKTAKE_SUBMIT", taskNo, "COMPLETED", + "STOCKTAKE_SUBMIT:" + key, requestHash, + "盘点提交,差异位置 " + task.getDifferenceCount() + " 个;原因:" + request.reason().trim()); + PdaStocktakeResponses.Detail response = detail(requireTask(warehouseId, taskNo, false)); + complete(action, response); + return response; + } + + private PdaStocktakeResponses.Detail detail(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Detail(summary(task), items.views(task.getId())); + } + + private PdaStocktakeResponses.Summary summary(PdaStocktakeTask task) { + return new PdaStocktakeResponses.Summary(task.getTaskNo(), task.getScopeType(), task.getScopeCode(), + task.getStatus(), intValue(task.getPositionCount()), intValue(task.getCountedCount()), + intValue(task.getDifferenceCount()), value(task.getVersion()), task.getCreatedAt(), task.getSubmittedAt()); + } + + private PdaStocktakeTask requireTask(long warehouseId, String taskNo, boolean lock) { + String normalized = required(taskNo, "Stocktake task number is required"); + PdaStocktakeTask task = lock ? tasks.lock(warehouseId, normalized) : tasks.find(warehouseId, normalized); + if (task == null) throw notFound("Stocktake task was not found in the bound warehouse"); + return task; + } + + private PdaStocktakeAction beginAction(long warehouseId, String operatorKey, String key, String type, + String requestHash, String taskNo) { + PdaStocktakeAction existing = actions.find(warehouseId, operatorKey, key); + if (existing != null) return replay(existing, type, requestHash); + PdaStocktakeAction action = new PdaStocktakeAction(); + action.setId(IdWorker.getId()); action.setOverseasWarehouseId(warehouseId); action.setOperatorKey(operatorKey); + action.setIdempotencyKey(key); action.setActionType(type); action.setRequestHash(requestHash); + PdaStocktakeTask task = tasks.find(warehouseId, taskNo); action.setTaskId(task == null ? null : task.getId()); + action.setStatus("PROCESSING"); action.setCreatedAt(LocalDateTime.now()); action.setUpdatedAt(action.getCreatedAt()); + try { actions.insert(action); return action; } + catch (DuplicateKeyException duplicate) { + PdaStocktakeAction winner = actions.find(warehouseId, operatorKey, key); + if (winner == null) throw duplicate; + return replay(winner, type, requestHash); + } + } + + private PdaStocktakeAction replay(PdaStocktakeAction action, String type, String requestHash) { + if (!type.equals(action.getActionType()) || !requestHash.equals(action.getRequestHash())) { + throw conflict("Idempotency key was reused with a different stocktake action"); + } + if (!"COMPLETED".equals(action.getStatus())) throw conflict("Stocktake action is still processing"); + return action; + } + + private void complete(PdaStocktakeAction action, Object response) { + action.setStatus("COMPLETED"); action.setResultJson(write(response)); action.setUpdatedAt(LocalDateTime.now()); + actions.updateById(action); + } + + private T read(PdaStocktakeAction action, Class type) { + try { return objectMapper.readValue(action.getResultJson(), type); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot read stocktake replay", error); } + } + + private void ledger(PdaStocktakeTask task, PdaStocktakeItem item, InventoryPosition position, + InventoryBalance balance, String operator, String key, String reason, + long before, long delta, long after, LocalDateTime now) { + InventoryLedger row = new InventoryLedger(); + row.setId(IdWorker.getId()); row.setCustomerId(item.getCustomerId()); row.setBalanceId(balance.getId()); + row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(task.getOverseasWarehouseId())); + row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); + row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); + row.setSkuCode(item.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("STOCKTAKE_ADJUST"); row.setBusinessOrderId(task.getId()); row.setBusinessOrderNo(task.getTaskNo()); + row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); + row.setIdempotencyKey("STOCKTAKE:" + task.getId() + ":" + item.getId()); + row.setStatusFrom("PHYSICAL"); row.setStatusTo("PHYSICAL"); row.setBeforePackages(before); + row.setDeltaPackages(delta); row.setAfterPackages(after); row.setOperatorId(operator); row.setRemark(reason.trim()); + row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); + } + + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException error) { throw new IllegalStateException("Cannot serialize stocktake operation", error); } + } + private static String status(String value) { + String normalized = trim(value); if (normalized == null) return null; + normalized = normalized.toUpperCase(); + if (!List.of("IN_PROGRESS", "SUBMITTED").contains(normalized)) throw badRequest("Unsupported stocktake status"); + return normalized; + } + private static long physical(InventoryPosition value) { return value(value.getAvailablePackages()) + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long physicalAfter(InventoryPosition value, long available) { return available + value(value.getHoldPackages()) + + value(value.getAllocatedPackages()) + value(value.getPickedPackages()) + value(value.getReceivingPackages()); } + private static long value(Long value) { return value == null ? 0 : value; } + private static int intValue(Integer value) { return value == null ? 0 : value; } + private static String required(String value, String message) { String normalized = trim(value); if (normalized == null) throw badRequest(message); return normalized; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java new file mode 100644 index 000000000..893536c8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/PdaRelabelMapperConfiguration.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("com.baiyue.lastleg.relabel.pda.mapper") +public class PdaRelabelMapperConfiguration { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java new file mode 100644 index 000000000..6d89ac391 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.service.PdaRelabelService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +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.PathVariable; +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.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/api/pda/v1/last-leg/relabel-tasks") +@PreAuthorize("hasAuthority('lastleg:pda:operate')") +public class PdaRelabelController { + public static final String OPERATE_PERMISSION = "lastleg:pda:operate"; + private static final String PICKER_ROLE = "ROLE_WAREHOUSE_PICKER"; + + @Resource private PdaRelabelService service; + + @GetMapping("/candidates") + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user) { + Identity identity = identity(user); + return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); + } + + @GetMapping("/{relabelNo}") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo) { + Identity identity = identity(user); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), relabelNo)); + } + + @PostMapping("/{relabelNo}/claim") + public ApiResponse claim(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Claim request) { + Identity identity = identity(user); + return ApiResponse.success(service.claim(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units") + public ApiResponse scan(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Scan request) { + Identity identity = identity(user); + return ApiResponse.success(service.scan(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/units/skip") + public ApiResponse skip(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Skip request) { + Identity identity = identity(user); + return ApiResponse.success(service.skip(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/exceptions") + public ApiResponse exception(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.ExceptionFact request) { + Identity identity = identity(user); + return ApiResponse.success(service.exception(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping(value = "/{relabelNo}/photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadPhoto(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @RequestPart("file") MultipartFile file) { + Identity identity = identity(user); + return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), identity.overseasWarehouseId(), + identity.userId(), relabelNo, file)); + } + + @PostMapping("/{relabelNo}/complete") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Complete request) { + Identity identity = identity(user); + return ApiResponse.success(service.complete(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + @PostMapping("/{relabelNo}/review") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String relabelNo, @Valid @RequestBody PdaRelabelRequests.Review request) { + Identity identity = identity(user); + return ApiResponse.success(service.review(identity.operatorKey(), identity.operatorName(), + identity.overseasWarehouseId(), relabelNo, request)); + } + + static Identity identity(AuthenticatedUser user) { + if (user == null || user.userId() == null || user.tenantId() == null || user.tenantId() != 0L + || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "Authenticated platform warehouse PDA identity is required"); + } + if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION) + || !user.permissions().contains(PICKER_ROLE)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "WAREHOUSE_PICKER and " + OPERATE_PERMISSION + " are required"); + } + if (user.overseasWarehouseId() == null || user.overseasWarehouseId() <= 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "PDA operator must be bound to one overseas warehouse"); + } + String operator = String.valueOf(user.userId()); + return new Identity(user.userId(), operator, + user.username() == null || user.username().isBlank() ? operator : user.username(), + user.overseasWarehouseId()); + } + + record Identity(long userId, String operatorKey, String operatorName, long overseasWarehouseId) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java new file mode 100644 index 000000000..c363ac059 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class PdaRelabelRequests { + private PdaRelabelRequests() { } + + public record Claim(@NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Scan(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 255) String newLabel, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Skip(@NotNull @Positive Long itemId, + @NotBlank @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record ExceptionFact(@NotNull @Positive Long itemId, + @Size(max = 255) String oldLabel, + @NotBlank @Size(max = 32) String exceptionCode, + @NotBlank @Size(max = 1000) String reason, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record Complete(@NotEmpty @Size(max = 20) List<@Positive Long> photoFileIds, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } + + public record Review(@NotBlank @Size(max = 16) String decision, + @NotNull @Min(0) Integer expectedVersion, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) String remark) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java new file mode 100644 index 000000000..72724a325 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public final class PdaRelabelResponses { + private PdaRelabelResponses() { } + + public record Candidate(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String applicationStatus, String taskStatus, int itemCount, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages, int version, boolean claimedByCurrentOperator) { } + + public record Detail(String relabelNo, String customerOrderNo, LocalDateTime labelDeadline, + String contactName, String contactPhone, String applicationStatus, + String taskStatus, int expectedPackages, int relabelledPackages, + int skippedPackages, int exceptionPackages, int version, + String operatorName, String reviewerName, List completionPhotoFileIds, + String completionRemark, String reviewDecision, String reviewRemark, + LocalDateTime claimedAt, LocalDateTime submittedAt, LocalDateTime reviewedAt, + List items, List unitFacts, List history) { } + + public record Item(Long itemId, String skuCode, String productNameZh, String shipmentNo, + String trackingCode, String newSkuCode, String labelRequirement, + String relabelFileId, String relabelFileName, String relabelFileUrl, + int expectedPackages, int relabelledPackages, int skippedPackages, + int exceptionPackages) { } + + public record UnitFact(Long factId, Long itemId, String oldLabel, String newLabel, + String result, String exceptionCode, String reason, + String operatorName, LocalDateTime occurredAt) { } + + public record History(String action, String fromStatus, String toStatus, + String operatorName, String detail, LocalDateTime createdAt) { } + + public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java new file mode 100644 index 000000000..674a07be8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.relabel.pda.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_relabel_pda_history") +public class PdaRelabelHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private String customerId; + private String action; + private String fromStatus; + private String toStatus; + private String operatorKey; + private String operatorName; + private String detailJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java new file mode 100644 index 000000000..991bbea9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.relabel.pda.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_relabel_pda_operation") +public class PdaRelabelOperation { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private String customerId; + private String operatorKey; + private String action; + private String idempotencyKey; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java new file mode 100644 index 000000000..f2e07ea86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.relabel.pda.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_relabel_pda_task") +public class PdaRelabelTask { + @TableId(type = IdType.INPUT) private Long id; + private Long applicationId; + private String customerId; + private String relabelNo; + private Long overseasWarehouseId; + private String status; + private String operatorKey; + private String operatorName; + private String reviewerKey; + private String reviewerName; + private Integer expectedPackages; + private Integer relabelledPackages; + private Integer skippedPackages; + private Integer exceptionPackages; + private String completionPhotoFileIds; + private String completionRemark; + private String reviewDecision; + private String reviewRemark; + private LocalDateTime claimedAt; + private LocalDateTime submittedAt; + private LocalDateTime reviewedAt; + private Integer version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java new file mode 100644 index 000000000..a6ece68da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.relabel.pda.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_relabel_pda_unit_fact") +public class PdaRelabelUnitFact { + @TableId(type = IdType.INPUT) private Long id; + private Long taskId; + private Long applicationId; + private Long itemId; + private String customerId; + private String oldLabel; + private String newLabel; + private String result; + private String exceptionCode; + private String reason; + private String operatorKey; + private String operatorName; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java new file mode 100644 index 000000000..12cf36e23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -0,0 +1,109 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; + +@Component +public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read relabel photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken) + .header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo upload failed"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo upload failed"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "File service returned no relabel photo metadata"); + } + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0L : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new ResponseStatusException(result.getStatusCode().is4xxClientError() + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "Relabel photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + requireSuccess(response, "Relabel photo does not exist or its ownership is invalid"); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) + || !String.valueOf(taskId).equals(data.businessId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Relabel photo metadata does not match the PDA task"); + } + } + + private static long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Customer ID must be numeric", exception); + } + } + + private static void requireSuccess(MetadataEnvelope response, String fallback) { + if (response == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, fallback); + if ("200".equals(response.code())) return; + HttpStatus status = switch (String.valueOf(response.code())) { + case "A0400", "A0422" -> HttpStatus.BAD_REQUEST; + case "A0403" -> HttpStatus.FORBIDDEN; + case "A0404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.SERVICE_UNAVAILABLE; + }; + throw new ResponseStatusException(status, + response.message() == null || response.message().isBlank() ? fallback : response.message()); + } + + private record MetadataEnvelope(String code, String message, Metadata data) { } + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java new file mode 100644 index 000000000..c309f5dd8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.relabel.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface RelabelPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java new file mode 100644 index 000000000..ccd126e40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PdaRelabelHistoryMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java new file mode 100644 index 000000000..8e96b2050 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelOperationMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_operation WHERE operator_key=#{operatorKey} AND idempotency_key=#{key} LIMIT 1") + PdaRelabelOperation find(@Param("operatorKey") String operatorKey, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java new file mode 100644 index 000000000..ee6b56521 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface PdaRelabelTaskMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1") + PdaRelabelTask find(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Select("SELECT * FROM ll_relabel_pda_task WHERE customer_id=#{customerId} AND application_id=#{applicationId} LIMIT 1 FOR UPDATE") + PdaRelabelTask lock(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + @Update(""" + UPDATE ll_relabel_pda_task + SET status=#{task.status}, operator_key=#{task.operatorKey}, operator_name=#{task.operatorName}, + reviewer_key=#{task.reviewerKey}, reviewer_name=#{task.reviewerName}, + relabelled_packages=#{task.relabelledPackages}, skipped_packages=#{task.skippedPackages}, + exception_packages=#{task.exceptionPackages}, completion_photo_file_ids=#{task.completionPhotoFileIds}, + completion_remark=#{task.completionRemark}, review_decision=#{task.reviewDecision}, + review_remark=#{task.reviewRemark}, submitted_at=#{task.submittedAt}, reviewed_at=#{task.reviewedAt}, + version=version+1, updated_at=#{task.updatedAt} + WHERE id=#{task.id} AND customer_id=#{task.customerId} AND version=#{expectedVersion} + """) + int updateWithVersion(@Param("task") PdaRelabelTask task, @Param("expectedVersion") Integer expectedVersion); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java new file mode 100644 index 000000000..af9100c89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.relabel.pda.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface PdaRelabelUnitFactMapper extends BaseMapper { + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE old_label=#{oldLabel} LIMIT 1") + PdaRelabelUnitFact findOldLabel(@Param("oldLabel") String oldLabel); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java new file mode 100644 index 000000000..a9fc30a0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java @@ -0,0 +1,480 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelResponses; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +@Service +public class PdaRelabelService { + public static final String PROCESSING = "PROCESSING"; + public static final String WAIT_REVIEW = "WAIT_REVIEW"; + public static final String COMPLETED = "COMPLETED"; + private static final long MAX_PHOTO_SIZE = 10L * 1024 * 1024; + private static final Set EXCEPTION_CODES = Set.of( + "DAMAGED", "MISSING_LABEL", "LABEL_MISMATCH", "UNREADABLE", "OTHER"); + + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + @Resource private PdaRelabelTaskMapper tasks; + @Resource private PdaRelabelUnitFactMapper unitFacts; + @Resource private PdaRelabelOperationMapper operations; + @Resource private PdaRelabelHistoryMapper histories; + @Resource private RelabelApplicationService applicationService; + @Resource private RelabelPhotoFileGateway photoFiles; + @Resource private ObjectMapper sourceObjectMapper; + private ObjectMapper objectMapper; + + @PostConstruct + void initializeObjectMapper() { + objectMapper = sourceObjectMapper.copy().findAndRegisterModules() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + } + + @Transactional(readOnly = true) + public List candidates(String operatorKey, long overseasWarehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(RelabelApplication::getOverseasWarehouseId, String.valueOf(overseasWarehouseId)) + .in(RelabelApplication::getStatus, RelabelApplicationService.PENDING_PROCESSING, PROCESSING) + .orderByAsc(RelabelApplication::getLabelDeadline) + .orderByAsc(RelabelApplication::getCreatedAt)) + .stream().map(application -> candidate(application, + tasks.find(application.getCustomerId(), application.getId()), operatorKey)).toList(); + } + + @Transactional(readOnly = true) + public PdaRelabelResponses.Detail detail(String operatorKey, long overseasWarehouseId, String relabelNo) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + return detail(application, tasks.find(application.getCustomerId(), application.getId())); + } + + @Transactional + public PdaRelabelResponses.Detail claim(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Claim request) { + ClaimCommand command = new ClaimCommand(relabelNo, request.idempotencyKey()); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "CLAIM", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!Set.of(RelabelApplicationService.PENDING_PROCESSING, PROCESSING).contains(application.getStatus())) { + throw conflict("Relabel application is not executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task != null) { + if (!operatorKey.equals(task.getOperatorKey()) || !PROCESSING.equals(task.getStatus())) { + throw conflict("Relabel task is already claimed or awaiting review"); + } + } else { + LocalDateTime now = LocalDateTime.now(); + task = new PdaRelabelTask(); + task.setId(IdWorker.getId()); + task.setApplicationId(application.getId()); task.setCustomerId(application.getCustomerId()); + task.setRelabelNo(application.getRelabelNo()); task.setOverseasWarehouseId(overseasWarehouseId); + task.setStatus(PROCESSING); task.setOperatorKey(operatorKey); task.setOperatorName(operatorName); + task.setExpectedPackages(expectedPackages(application.getId())); + task.setRelabelledPackages(0); task.setSkippedPackages(0); task.setExceptionPackages(0); + task.setClaimedAt(now); task.setVersion(0); task.setCreatedAt(now); task.setUpdatedAt(now); + try { tasks.insert(task); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel task was claimed concurrently"); } + if (RelabelApplicationService.PENDING_PROCESSING.equals(application.getStatus())) { + applicationService.changeStatus(application.getCustomerId(), application.getRelabelNo(), operatorKey, + new RelabelRequests.ChangeStatus(PROCESSING, "PDA relabel task claimed")); + application.setStatus(PROCESSING); + } + insertHistory(task, "CLAIM", null, PROCESSING, operatorKey, operatorName, + Map.of("relabelNo", relabelNo)); + } + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "CLAIM", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail scan(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Scan request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "RELABEL", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), request.newLabel(), "RELABELLED", null, null, + request.expectedVersion(), item -> { + if (!item.getNewSkuCode().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Scanned new label does not match the approved new SKU"); + } + if (request.oldLabel().trim().equalsIgnoreCase(request.newLabel().trim())) { + throw conflict("Old and new labels must be different"); + } + }); + } + + @Transactional + public PdaRelabelResponses.Detail skip(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Skip request) { + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "SKIP", request.idempotencyKey(), + request, request.itemId(), request.oldLabel(), null, "SKIPPED", null, request.reason(), + request.expectedVersion(), ignored -> { }); + } + + @Transactional + public PdaRelabelResponses.Detail exception(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.ExceptionFact request) { + String code = request.exceptionCode().trim().toUpperCase(Locale.ROOT); + if (!EXCEPTION_CODES.contains(code)) throw badRequest("Unsupported relabel exception code"); + return unitAction(operatorKey, operatorName, overseasWarehouseId, relabelNo, "EXCEPTION", + request.idempotencyKey(), request, request.itemId(), trim(request.oldLabel()), null, + "EXCEPTION", code, request.reason(), request.expectedVersion(), ignored -> { }); + } + + public PdaRelabelResponses.Photo uploadPhoto(String operatorKey, long overseasWarehouseId, long operatorId, + String relabelNo, MultipartFile file) { + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, false); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, false); + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel photos can only be uploaded while processing"); + if (file == null || file.isEmpty()) throw badRequest("Relabel photo is required"); + if (file.getSize() > MAX_PHOTO_SIZE) throw badRequest("Relabel photo exceeds 10 MB"); + if (file.getContentType() == null || !file.getContentType().toLowerCase(Locale.ROOT).startsWith("image/")) { + throw badRequest("Relabel photo must be an image"); + } + RelabelPhotoFileGateway.UploadedFile uploaded = photoFiles.upload(application.getCustomerId(), + task.getId(), operatorId, file); + return new PdaRelabelResponses.Photo(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + + @Transactional + public PdaRelabelResponses.Detail complete(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Complete request) { + CompleteCommand command = new CompleteCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "COMPLETE", request.idempotencyKey(), command); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, request.expectedVersion()); + List detailItems = itemList(application); + List facts = factList(task); + for (RelabelItem item : detailItems) { + long actual = facts.stream().filter(fact -> item.getId().equals(fact.getItemId())).count(); + if (actual != value(item.getNotifiedPackageCount())) { + throw conflict("Every relabel item must have exactly one fact per notified package"); + } + } + List photoIds = request.photoFileIds().stream().distinct().toList(); + if (photoIds.size() != request.photoFileIds().size()) throw conflict("Relabel photo IDs must be unique"); + photoIds.forEach(fileId -> photoFiles.validate(application.getCustomerId(), task.getId(), fileId)); + String before = task.getStatus(); + task.setStatus(WAIT_REVIEW); task.setCompletionPhotoFileIds(write(photoIds)); + task.setCompletionRemark(trim(request.remark())); task.setSubmittedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + insertHistory(task, "COMPLETE", before, WAIT_REVIEW, operatorKey, operatorName, + Map.of("photoFileIds", photoIds, "remark", Objects.toString(trim(request.remark()), ""))); + application.setStatus(PROCESSING); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "COMPLETE", request.idempotencyKey(), command, result); + return result; + } + + @Transactional + public PdaRelabelResponses.Detail review(String operatorKey, String operatorName, long overseasWarehouseId, + String relabelNo, PdaRelabelRequests.Review request) { + ReviewCommand command = new ReviewCommand(relabelNo, request); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + "REVIEW", request.idempotencyKey(), command); + if (replay != null) return replay; + String decision = request.decision().trim().toUpperCase(Locale.ROOT); + if (!Set.of("APPROVE", "REWORK").contains(decision)) throw badRequest("Review decision must be APPROVE or REWORK"); + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = tasks.lock(application.getCustomerId(), application.getId()); + if (task == null) throw notFound("PDA relabel task was not found"); + if (!Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId)) { + throw notFound("PDA relabel task was not found in the bound overseas warehouse"); + } + if (!WAIT_REVIEW.equals(task.getStatus())) throw conflict("Relabel task is not awaiting review"); + requireVersion(task, request.expectedVersion()); + String before = task.getStatus(); + String after = "APPROVE".equals(decision) ? COMPLETED : PROCESSING; + task.setStatus(after); task.setReviewerKey(operatorKey); task.setReviewerName(operatorName); + task.setReviewDecision(decision); task.setReviewRemark(trim(request.remark())); + task.setReviewedAt(LocalDateTime.now()); + updateTask(task, request.expectedVersion()); + if ("APPROVE".equals(decision)) { + applicationService.changeStatus(application.getCustomerId(), relabelNo, operatorKey, + new RelabelRequests.ChangeStatus(COMPLETED, request.remark())); + application.setStatus(COMPLETED); + } + insertHistory(task, "REVIEW_" + decision, before, after, operatorKey, operatorName, + Map.of("decision", decision, "remark", Objects.toString(trim(request.remark()), ""))); + PdaRelabelResponses.Detail result = detail(application, task); + saveOperation(task, operatorKey, "REVIEW", request.idempotencyKey(), command, result); + return result; + } + + private PdaRelabelResponses.Detail unitAction(String operatorKey, String operatorName, + long overseasWarehouseId, String relabelNo, String action, String idempotencyKey, Object command, + Long itemId, String oldLabel, String newLabel, String resultCode, String exceptionCode, String reason, + Integer expectedVersion, Consumer validation) { + UnitCommand replayCommand = new UnitCommand(relabelNo, action, command); + PdaRelabelResponses.Detail replay = replay(operatorKey, overseasWarehouseId, relabelNo, + action, idempotencyKey, replayCommand); + if (replay != null) return replay; + RelabelApplication application = requireApplication(relabelNo, overseasWarehouseId, true); + PdaRelabelTask task = requireOwnedTask(application, operatorKey, true); + requireVersion(task, expectedVersion); + RelabelItem item = requireItem(application, itemId); + validation.accept(item); + List existingFacts = factList(task); + long itemFacts = existingFacts.stream().filter(fact -> itemId.equals(fact.getItemId())).count(); + if (itemFacts >= value(item.getNotifiedPackageCount())) { + throw conflict("Relabel item already has all required package facts"); + } + String normalizedOldLabel = trim(oldLabel); + if (normalizedOldLabel != null && unitFacts.findOldLabel(normalizedOldLabel) != null) { + throw conflict("Old label was already processed by another relabel unit"); + } + PdaRelabelUnitFact fact = new PdaRelabelUnitFact(); + fact.setId(IdWorker.getId()); fact.setTaskId(task.getId()); fact.setApplicationId(application.getId()); + fact.setItemId(itemId); fact.setCustomerId(application.getCustomerId()); + fact.setOldLabel(normalizedOldLabel); fact.setNewLabel(trim(newLabel)); fact.setResult(resultCode); + fact.setExceptionCode(exceptionCode); fact.setReason(trim(reason)); + fact.setOperatorKey(operatorKey); fact.setOperatorName(operatorName); fact.setOccurredAt(LocalDateTime.now()); + try { unitFacts.insert(fact); } + catch (DuplicateKeyException duplicate) { throw conflict("Old label was processed concurrently"); } + if ("RELABELLED".equals(resultCode)) task.setRelabelledPackages(value(task.getRelabelledPackages()) + 1); + if ("SKIPPED".equals(resultCode)) task.setSkippedPackages(value(task.getSkippedPackages()) + 1); + if ("EXCEPTION".equals(resultCode)) task.setExceptionPackages(value(task.getExceptionPackages()) + 1); + updateTask(task, expectedVersion); + insertHistory(task, action, PROCESSING, PROCESSING, operatorKey, operatorName, + Map.of("factId", fact.getId(), "itemId", itemId, "result", resultCode)); + PdaRelabelResponses.Detail response = detail(application, task); + saveOperation(task, operatorKey, action, idempotencyKey, replayCommand, response); + return response; + } + + private RelabelApplication requireApplication(String relabelNo, long overseasWarehouseId, boolean lock) { + RelabelApplication application = lock ? applications.lockAnyByRelabelNo(relabelNo) + : applications.findAnyByRelabelNo(relabelNo); + if (application == null) throw notFound("Relabel application was not found"); + long applicationWarehouse; + try { applicationWarehouse = Long.parseLong(application.getOverseasWarehouseId()); } + catch (Exception invalid) { throw conflict("Relabel application has no valid overseas warehouse"); } + if (applicationWarehouse != overseasWarehouseId) { + throw notFound("Relabel application was not found in the bound overseas warehouse"); + } + return application; + } + + private PdaRelabelTask requireOwnedTask(RelabelApplication application, String operatorKey, boolean lock) { + if (!PROCESSING.equals(application.getStatus())) { + throw conflict("Relabel application is no longer executable"); + } + PdaRelabelTask task = lock ? tasks.lock(application.getCustomerId(), application.getId()) + : tasks.find(application.getCustomerId(), application.getId()); + if (task == null) throw conflict("Claim the relabel task before operating"); + if (!operatorKey.equals(task.getOperatorKey())) throw conflict("Relabel task is claimed by another operator"); + if (!Objects.equals(task.getOverseasWarehouseId(), Long.parseLong(application.getOverseasWarehouseId()))) { + throw conflict("Relabel task warehouse does not match the approved application"); + } + if (!PROCESSING.equals(task.getStatus())) throw conflict("Relabel task is not in processing status"); + return task; + } + + private RelabelItem requireItem(RelabelApplication application, Long itemId) { + RelabelItem item = items.selectOne(new LambdaQueryWrapper() + .eq(RelabelItem::getId, itemId).eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId())); + if (item == null) throw notFound("Relabel item does not belong to the current application"); + return item; + } + + private void requireVersion(PdaRelabelTask task, Integer expectedVersion) { + if (!Objects.equals(task.getVersion(), expectedVersion)) { + throw conflict("Relabel task version changed; reload before retrying"); + } + } + + private void updateTask(PdaRelabelTask task, Integer expectedVersion) { + task.setUpdatedAt(LocalDateTime.now()); + if (tasks.updateWithVersion(task, expectedVersion) != 1) { + throw conflict("Relabel task version changed concurrently"); + } + task.setVersion(expectedVersion + 1); + } + + private int expectedPackages(Long applicationId) { + return itemList(applicationId).stream().mapToInt(item -> value(item.getNotifiedPackageCount())).sum(); + } + + private List itemList(RelabelApplication application) { return itemList(application.getId()); } + private List itemList(Long applicationId) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, applicationId).orderByAsc(RelabelItem::getId)); + } + + private List factList(PdaRelabelTask task) { + return unitFacts.selectList(new LambdaQueryWrapper() + .eq(PdaRelabelUnitFact::getCustomerId, task.getCustomerId()) + .eq(PdaRelabelUnitFact::getTaskId, task.getId()) + .orderByAsc(PdaRelabelUnitFact::getOccurredAt).orderByAsc(PdaRelabelUnitFact::getId)); + } + + private PdaRelabelResponses.Candidate candidate(RelabelApplication application, PdaRelabelTask task, + String operatorKey) { + return new PdaRelabelResponses.Candidate(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + value(application.getItemCount()), task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task != null && operatorKey.equals(task.getOperatorKey())); + } + + private PdaRelabelResponses.Detail detail(RelabelApplication application, PdaRelabelTask task) { + List detailItems = itemList(application); + List facts = task == null ? List.of() : factList(task); + Map> factsByItem = facts.stream() + .collect(Collectors.groupingBy(PdaRelabelUnitFact::getItemId, LinkedHashMap::new, Collectors.toList())); + List itemResponses = detailItems.stream().map(item -> { + List itemFacts = factsByItem.getOrDefault(item.getId(), List.of()); + return new PdaRelabelResponses.Item(item.getId(), item.getSkuCode(), item.getProductNameZh(), + item.getShipmentNo(), item.getTrackingCode(), item.getNewSkuCode(), item.getLabelRequirement(), + item.getRelabelFileId(), item.getRelabelFileName(), item.getRelabelFileUrl(), + value(item.getNotifiedPackageCount()), count(itemFacts, "RELABELLED"), count(itemFacts, "SKIPPED"), + count(itemFacts, "EXCEPTION")); + }).toList(); + List factResponses = facts.stream().map(fact -> + new PdaRelabelResponses.UnitFact(fact.getId(), fact.getItemId(), fact.getOldLabel(), fact.getNewLabel(), + fact.getResult(), fact.getExceptionCode(), fact.getReason(), fact.getOperatorName(), fact.getOccurredAt())).toList(); + List historyResponses = task == null ? List.of() : histories.selectList( + new LambdaQueryWrapper().eq(PdaRelabelHistory::getCustomerId, application.getCustomerId()) + .eq(PdaRelabelHistory::getTaskId, task.getId()) + .orderByAsc(PdaRelabelHistory::getCreatedAt).orderByAsc(PdaRelabelHistory::getId)).stream() + .map(history -> new PdaRelabelResponses.History(history.getAction(), history.getFromStatus(), + history.getToStatus(), history.getOperatorName(), history.getDetailJson(), history.getCreatedAt())).toList(); + return new PdaRelabelResponses.Detail(application.getRelabelNo(), application.getCustomerOrderNo(), + application.getLabelDeadline(), application.getContactName(), application.getContactPhone(), + application.getStatus(), task == null ? "AVAILABLE" : task.getStatus(), + task == null ? value(application.getNotifiedPackageCount()) : value(task.getExpectedPackages()), + task == null ? 0 : value(task.getRelabelledPackages()), task == null ? 0 : value(task.getSkippedPackages()), + task == null ? 0 : value(task.getExceptionPackages()), task == null ? 0 : value(task.getVersion()), + task == null ? null : task.getOperatorName(), task == null ? null : task.getReviewerName(), + task == null ? List.of() : readIds(task.getCompletionPhotoFileIds()), + task == null ? null : task.getCompletionRemark(), task == null ? null : task.getReviewDecision(), + task == null ? null : task.getReviewRemark(), task == null ? null : task.getClaimedAt(), + task == null ? null : task.getSubmittedAt(), task == null ? null : task.getReviewedAt(), + itemResponses, factResponses, historyResponses); + } + + private void insertHistory(PdaRelabelTask task, String action, String from, String to, + String operatorKey, String operatorName, Object detail) { + PdaRelabelHistory history = new PdaRelabelHistory(); + history.setId(IdWorker.getId()); history.setTaskId(task.getId()); + history.setApplicationId(task.getApplicationId()); history.setCustomerId(task.getCustomerId()); + history.setAction(action); history.setFromStatus(from); history.setToStatus(to); + history.setOperatorKey(operatorKey); history.setOperatorName(operatorName); + history.setDetailJson(write(detail)); history.setCreatedAt(LocalDateTime.now()); + histories.insert(history); + } + + private PdaRelabelResponses.Detail replay(String operatorKey, long overseasWarehouseId, String relabelNo, + String action, String key, Object request) { + PdaRelabelOperation operation = operations.find(operatorKey, idempotencyKey(key)); + if (operation == null) return null; + PdaRelabelTask task = tasks.selectById(operation.getTaskId()); + if (task == null || !Objects.equals(task.getOverseasWarehouseId(), overseasWarehouseId) + || !Objects.equals(task.getRelabelNo(), relabelNo) + || !Objects.equals(task.getCustomerId(), operation.getCustomerId())) { + throw notFound("PDA relabel operation was not found in the bound overseas warehouse"); + } + if (!action.equals(operation.getAction()) || !hash(request).equals(operation.getRequestHash())) { + throw conflict("Idempotency key was already used with a different relabel request"); + } + try { return objectMapper.readValue(operation.getResultJson(), PdaRelabelResponses.Detail.class); } + catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read PDA relabel replay", exception); } + } + + private void saveOperation(PdaRelabelTask task, String operatorKey, String action, String key, + Object request, PdaRelabelResponses.Detail result) { + PdaRelabelOperation operation = new PdaRelabelOperation(); + operation.setId(IdWorker.getId()); operation.setTaskId(task.getId()); operation.setCustomerId(task.getCustomerId()); + operation.setOperatorKey(operatorKey); operation.setAction(action); operation.setIdempotencyKey(idempotencyKey(key)); + operation.setRequestHash(hash(request)); operation.setResultJson(write(result)); operation.setCreatedAt(LocalDateTime.now()); + try { operations.insert(operation); } + catch (DuplicateKeyException duplicate) { throw conflict("Relabel operation was submitted concurrently"); } + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (Exception exception) { throw new IllegalStateException(exception); } + } + + private String write(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException(exception); } + } + + private List readIds(String value) { + if (!StringUtils.hasText(value)) return List.of(); + try { + return objectMapper.readValue(value, objectMapper.getTypeFactory().constructCollectionType(List.class, Long.class)); + } catch (JsonProcessingException exception) { throw new IllegalStateException("Cannot read relabel photo IDs", exception); } + } + + private static int count(List facts, String result) { + return (int) facts.stream().filter(fact -> result.equals(fact.getResult())).count(); + } + private static int value(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String idempotencyKey(String value) { return value == null ? null : value.trim(); } + private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + + private record ClaimCommand(String relabelNo, String idempotencyKey) { } + private record UnitCommand(String relabelNo, String action, Object request) { } + private record CompleteCommand(String relabelNo, PdaRelabelRequests.Complete request) { } + private record ReviewCommand(String relabelNo, PdaRelabelRequests.Review request) { } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 201ed9d60..893cea457 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -744,6 +744,72 @@ CREATE TABLE IF NOT EXISTS ll_pda_operation_history ( KEY idx_ll_pda_history_business(overseas_warehouse_id,operation_type,business_key) ) ENGINE=InnoDB COMMENT='海外PDA真实作业历史'; +CREATE TABLE IF NOT EXISTS ll_pda_pallet_operation ( + id BIGINT NOT NULL COMMENT 'PDA托盘作业ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '设备幂等键', + operation_type VARCHAR(64) NOT NULL COMMENT '托盘作业类型', request_hash VARCHAR(128) NOT NULL COMMENT '请求内容摘要', + status VARCHAR(32) NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + result_json LONGTEXT NULL COMMENT '成功响应快照', remote_transitions_json LONGTEXT NULL COMMENT '实物托盘状态变更回执', + last_error VARCHAR(2000) 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_pda_pallet_idempotency(operator_key,idempotency_key), + KEY idx_ll_pda_pallet_warehouse(overseas_warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_task ( + id BIGINT NOT NULL COMMENT '盘点任务ID', task_no VARCHAR(64) NOT NULL COMMENT '盘点任务号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', scope_type VARCHAR(16) NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + scope_resource_id BIGINT NOT NULL COMMENT '盘点库位或托盘资源ID', scope_code VARCHAR(128) NOT NULL COMMENT '盘点库位码或托盘码快照', + status VARCHAR(32) NOT NULL COMMENT '盘点任务状态', position_count INT NOT NULL COMMENT '库存位置总数', + counted_count INT NOT NULL DEFAULT 0 COMMENT '已盘库存位置数', difference_count INT NOT NULL DEFAULT 0 COMMENT '存在差异的库存位置数', + created_by VARCHAR(128) NOT NULL COMMENT '创建操作账号标识', submitted_by VARCHAR(128) NULL COMMENT '提交操作账号标识', + submit_reason VARCHAR(500) NULL COMMENT '盘点差异提交原因', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash VARCHAR(128) NOT NULL COMMENT '创建请求哈希', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + submitted_at DATETIME(3) NULL COMMENT '提交完成时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_no(task_no), + UNIQUE KEY uk_ll_pda_stocktake_create(overseas_warehouse_id,created_by,idempotency_key), + KEY idx_ll_pda_stocktake_list(overseas_warehouse_id,status,created_at) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点任务'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_item ( + id BIGINT NOT NULL COMMENT '盘点明细ID', task_id BIGINT NOT NULL COMMENT '盘点任务ID', customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户标识', + balance_id BIGINT NOT NULL COMMENT '库存余额ID', position_id BIGINT NOT NULL COMMENT '库存位置ID', sku_code VARCHAR(128) NULL COMMENT 'SKU快照', + product_name VARCHAR(255) NULL COMMENT '品名快照', inbound_code VARCHAR(128) NULL COMMENT '入仓码快照', pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', location_id BIGINT NOT NULL COMMENT '库位ID快照', location_code VARCHAR(128) NOT NULL COMMENT '库位码快照', + book_packages BIGINT NOT NULL COMMENT '任务创建时账面总箱数', actual_packages BIGINT NULL COMMENT '现场实盘总箱数', + difference_packages BIGINT NULL COMMENT '实盘与账面箱数差异', status VARCHAR(32) NOT NULL COMMENT '盘点明细状态', + position_version BIGINT NOT NULL COMMENT '任务创建时库存位置版本', counted_by VARCHAR(128) NULL COMMENT '盘点操作账号标识', + version BIGINT NOT NULL DEFAULT 0 COMMENT '明细乐观锁版本', counted_at DATETIME(3) NULL COMMENT '盘点时间', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_pda_stocktake_item(task_id,position_id), KEY idx_ll_pda_stocktake_item_task(task_id,status,id) +) ENGINE=InnoDB COMMENT='海外PDA库存盘点位置明细'; + +CREATE TABLE IF NOT EXISTS ll_pda_stocktake_action ( + id BIGINT NOT NULL COMMENT '盘点动作ID', overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', idempotency_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + action_type VARCHAR(32) NOT NULL COMMENT '盘点动作类型', task_id BIGINT NULL COMMENT '盘点任务ID', request_hash VARCHAR(128) NOT NULL COMMENT '动作请求哈希', + status VARCHAR(32) NOT NULL COMMENT '动作处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_stocktake_action(overseas_warehouse_id,operator_key,idempotency_key), KEY idx_ll_pda_stocktake_action_task(task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA盘点动作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_pda_inventory_move ( + id BIGINT NOT NULL COMMENT '移库操作ID', move_no VARCHAR(64) NOT NULL COMMENT '移库操作单号', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'Token冻结的海外实际仓ID', pallet_id BIGINT NOT NULL COMMENT '整托移库的实物托盘ID', + pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', source_location_id BIGINT NOT NULL COMMENT '来源库位ID', + source_location_code VARCHAR(128) NOT NULL COMMENT '来源库位码快照', target_location_id BIGINT NOT NULL COMMENT '目标库位ID', + target_location_code VARCHAR(128) NOT NULL COMMENT '目标库位码快照', customer_count INT NOT NULL COMMENT '托盘涉及客户数', + position_count INT NOT NULL COMMENT '迁移库存位置数', package_count BIGINT NOT NULL COMMENT '迁移总箱数', + pallet_version_before INT NOT NULL COMMENT '迁移前托盘版本', pallet_version_after INT NULL COMMENT '迁移后托盘版本', + operator_key VARCHAR(128) NOT NULL COMMENT 'PDA操作账号标识', reason VARCHAR(500) NOT NULL COMMENT '移库原因', + idempotency_key VARCHAR(128) NOT NULL COMMENT '移库幂等键', request_hash VARCHAR(128) NOT NULL COMMENT '移库请求哈希', + status VARCHAR(32) NOT NULL COMMENT '移库处理状态', result_json LONGTEXT NULL COMMENT '成功响应JSON', + created_at DATETIME(3) NOT NULL COMMENT '创建时间', completed_at DATETIME(3) NULL COMMENT '完成时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_pda_inventory_move_no(move_no), + UNIQUE KEY uk_ll_pda_inventory_move_key(overseas_warehouse_id,operator_key,idempotency_key), + KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) +) 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', @@ -1236,6 +1302,90 @@ CREATE TABLE IF NOT EXISTS ll_relabel_status_history ( KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) ) ENGINE=InnoDB COMMENT='换标状态变更历史'; +CREATE TABLE IF NOT EXISTS ll_relabel_pda_task ( + id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + relabel_no VARCHAR(64) NOT NULL COMMENT '换标申请单号快照', + overseas_warehouse_id BIGINT NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + operator_key VARCHAR(128) NOT NULL COMMENT '认领操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '认领操作员名称快照', + reviewer_key VARCHAR(128) NULL COMMENT '复核账号标识', + reviewer_name VARCHAR(255) NULL COMMENT '复核员名称快照', + expected_packages INT NOT NULL COMMENT '申请单通知的应换标箱数', + relabelled_packages INT NOT NULL DEFAULT 0 COMMENT '已正常换标箱数', + skipped_packages INT NOT NULL DEFAULT 0 COMMENT '已说明原因跳过箱数', + exception_packages INT NOT NULL DEFAULT 0 COMMENT '已登记异常箱数', + completion_photo_file_ids JSON NULL COMMENT '换标完成现场照片文件ID列表', + completion_remark VARCHAR(1000) NULL COMMENT '完成提交备注', + review_decision VARCHAR(16) NULL COMMENT '最近复核结论:APPROVE或REWORK', + review_remark VARCHAR(1000) NULL COMMENT '最近复核备注', + claimed_at DATETIME(3) NOT NULL COMMENT '任务认领时间', + submitted_at DATETIME(3) NULL COMMENT '提交复核时间', + reviewed_at DATETIME(3) NULL COMMENT '最近复核时间', + version INT 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 '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_task_application (customer_id,application_id), + UNIQUE KEY uk_ll_relabel_pda_task_no (relabel_no), + KEY idx_ll_relabel_pda_task_warehouse (overseas_warehouse_id,status,updated_at), + KEY idx_ll_relabel_pda_task_operator (operator_key,status,updated_at) +) ENGINE=InnoDB COMMENT='海外PDA换标现场作业任务'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_unit_fact ( + id BIGINT NOT NULL COMMENT '逐件换标事实ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + item_id BIGINT NOT NULL COMMENT '换标申请明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + old_label VARCHAR(255) NULL COMMENT '扫描的实物旧标签或箱标', + new_label VARCHAR(255) NULL COMMENT '扫描的换标后新标签', + result VARCHAR(32) NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + exception_code VARCHAR(32) NULL COMMENT '异常编码', + reason VARCHAR(1000) NULL COMMENT '跳过或异常原因', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + occurred_at DATETIME(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_old_label (old_label), + KEY idx_ll_relabel_pda_unit_task (customer_id,task_id,occurred_at,id), + KEY idx_ll_relabel_pda_unit_item (customer_id,application_id,item_id) +) ENGINE=InnoDB COMMENT='海外PDA逐件换标扫码与异常事实'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_operation ( + id BIGINT NOT NULL COMMENT '换标PDA幂等操作ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + operator_key VARCHAR(128) NOT NULL COMMENT '请求操作账号标识', + action VARCHAR(32) NOT NULL COMMENT '幂等操作动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '设备生成的幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', + result_json LONGTEXT NOT NULL COMMENT '成功响应快照JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_relabel_pda_operation_key (operator_key,idempotency_key), + KEY idx_ll_relabel_pda_operation_task (customer_id,task_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标写操作幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_relabel_pda_history ( + id BIGINT NOT NULL COMMENT '换标PDA任务历史ID', + task_id BIGINT NOT NULL COMMENT '换标PDA任务ID', + application_id BIGINT NOT NULL COMMENT '换标申请单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + action VARCHAR(32) NOT NULL COMMENT '现场动作类型', + from_status VARCHAR(32) NULL COMMENT '动作前任务状态', + to_status VARCHAR(32) NOT NULL COMMENT '动作后任务状态', + operator_key VARCHAR(128) NOT NULL COMMENT '操作账号标识', + operator_name VARCHAR(255) NOT NULL COMMENT '操作员名称快照', + detail_json LONGTEXT NULL COMMENT '动作详情JSON', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + KEY idx_ll_relabel_pda_history_task (customer_id,task_id,created_at,id), + KEY idx_ll_relabel_pda_history_application (customer_id,application_id,created_at) +) ENGINE=InnoDB COMMENT='海外PDA换标任务不可变操作历史'; + CREATE TABLE IF NOT EXISTS ll_dropship_order_profile ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml new file mode 100644 index 000000000..e455f6f48 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -0,0 +1,256 @@ +openapi: 3.0.3 +info: + title: Lastleg Overseas PDA Warehouse Operations API + version: 1.0.0 + description: 海外仓PDA盘点、同仓整托移库、托盘作业与换标现场作业;全部范围从PDA Token解析。 +security: + - bearerAuth: [] +paths: + /api/pda/v1/last-leg/stocktakes: + get: + summary: 查询Token绑定仓盘点任务 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } } + responses: { '200': { description: 盘点任务分页 } } + post: + summary: 按库位或托盘创建盘点任务 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } } + responses: { '200': { description: 创建后的完整盘点任务 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}: + get: + summary: 查询盘点任务详情 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 盘点任务及冻结库存位置 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/counts: + post: + summary: 提交一个库存位置的实盘箱数 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } } + responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } } + /api/pda/v1/last-leg/stocktakes/{taskNo}/submit: + post: + summary: 提交盘点并按差异调整库存 + parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } } + responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } } + /api/pda/v1/last-leg/inventory-moves: + post: + summary: 同仓整托移库 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } } + responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/pallets/resolve: + post: + summary: 扫码查询托盘及库存内容 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } } + responses: { '200': { description: Token绑定仓托盘内容 } } + /api/pda/v1/last-leg/pallet-operations/assemble: + post: + summary: 将多个来源托盘的指定库存行组到目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletAssemble' } } } } + responses: { '200': { description: 组托及实物托盘状态结果 }, '409': { description: 目的仓、库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/disassemble: + post: + summary: 将来源托盘库存拆到一个或多个目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletDisassemble' } } } } + responses: { '200': { description: 拆托结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/swap: + post: + summary: 将整托库存更换到空目标托盘 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletSwap' } } } } + responses: { '200': { description: 换托结果 }, '409': { description: 目标托盘非空或版本冲突 } } + /api/pda/v1/last-leg/pallet-operations/unbind: + post: + summary: 解除整托库存与实物托盘绑定 + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PalletUnbind' } } } } + responses: { '200': { description: 解绑结果 }, '409': { description: 库存或资源版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/candidates: + get: + summary: 查询Token绑定仓可执行换标任务 + responses: { '200': { description: 待认领、处理中或待复核换标任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}: + get: + summary: 查询换标现场任务详情 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + responses: { '200': { description: 申请明细、逐件事实、照片与历史 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim: + post: + summary: 认领换标任务并推进申请至处理中 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } } + responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units: + post: + summary: 登记一件正常换标事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } } + responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip: + post: + summary: 登记一件有原因的跳过事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions: + post: + summary: 登记一件换标异常事实 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } } + responses: { '200': { description: 更新后的任务 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files: + post: + summary: 上传换标完成现场照片 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } } + responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete: + post: + summary: 提交现场任务等待复核,不提前完成申请 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } } + responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } } + /api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review: + post: + summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理 + parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } } + responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + ScanCode: + type: object + required: [code] + properties: { code: { type: string, minLength: 1, maxLength: 128 } } + IdempotentAction: + type: object + required: [idempotencyKey] + properties: { idempotencyKey: { type: string, minLength: 1, maxLength: 255 } } + StocktakeCreate: + type: object + required: [scopeType, scopeCode, idempotencyKey] + properties: + scopeType: { type: string, enum: [LOCATION, PALLET] } + scopeCode: { type: string, maxLength: 128 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeCount: + type: object + required: [positionId, actualPackages, expectedVersion, idempotencyKey] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + actualPackages: { type: integer, format: int64, minimum: 0 } + expectedVersion: { type: integer, format: int64, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + StocktakeSubmit: + type: object + required: [expectedVersion, reason, idempotencyKey] + properties: + expectedVersion: { type: integer, format: int64, minimum: 0 } + reason: { type: string, minLength: 1, maxLength: 500 } + idempotencyKey: { type: string, maxLength: 128 } + InventoryMove: + type: object + required: [sourcePalletCode, targetLocationCode, expectedPalletVersion, idempotencyKey, reason] + properties: + sourcePalletCode: { type: string, maxLength: 128 } + targetLocationCode: { type: string, maxLength: 128 } + expectedPalletVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 128 } + reason: { type: string, minLength: 1, maxLength: 500 } + PalletExpectation: + type: object + required: [palletCode, expectedVersion] + properties: + palletCode: { type: string, maxLength: 128 } + expectedVersion: { type: integer, minimum: 0 } + PalletMoveLine: + type: object + required: [positionId, packages, expectedPositionVersion] + properties: + positionId: { type: string, pattern: '^[0-9]+$' } + packages: { type: integer, format: int64, minimum: 1 } + expectedPositionVersion: { type: integer, format: int64, minimum: 0 } + PalletAssemble: + type: object + required: [idempotencyKey, target, sources, lines] + properties: + idempotencyKey: { type: string, maxLength: 128 } + target: { $ref: '#/components/schemas/PalletExpectation' } + sources: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletExpectation' } } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + reason: { type: string, maxLength: 500 } + PalletTargetMove: + type: object + required: [target, lines] + properties: + target: { $ref: '#/components/schemas/PalletExpectation' } + lines: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/PalletMoveLine' } } + PalletDisassemble: + type: object + required: [idempotencyKey, source, targets] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + targets: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/PalletTargetMove' } } + reason: { type: string, maxLength: 500 } + PalletSwap: + type: object + required: [idempotencyKey, source, target] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + target: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + PalletUnbind: + type: object + required: [idempotencyKey, source] + properties: + idempotencyKey: { type: string, maxLength: 128 } + source: { $ref: '#/components/schemas/PalletExpectation' } + reason: { type: string, maxLength: 500 } + RelabelUnit: + type: object + required: [itemId, oldLabel, newLabel, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + newLabel: { type: string, maxLength: 255 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelSkip: + type: object + required: [itemId, oldLabel, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255 } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelException: + type: object + required: [itemId, exceptionCode, reason, expectedVersion, idempotencyKey] + properties: + itemId: { type: string, pattern: '^[0-9]+$' } + oldLabel: { type: string, maxLength: 255, nullable: true } + exceptionCode: { type: string, enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER] } + reason: { type: string, maxLength: 1000 } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + RelabelComplete: + type: object + required: [photoFileIds, expectedVersion, idempotencyKey] + properties: + photoFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string, pattern: '^[0-9]+$' } } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } + RelabelReview: + type: object + required: [decision, expectedVersion, idempotencyKey] + properties: + decision: { type: string, enum: [APPROVE, REWORK] } + expectedVersion: { type: integer, minimum: 0 } + idempotencyKey: { type: string, maxLength: 255 } + remark: { type: string, maxLength: 1000, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 188ff9c41..a4005d745 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest { assertThat(result.palletId()).isEqualTo(2092040891261427713L); server.verify(); } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java new file mode 100644 index 000000000..1f728cc1d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.palletops.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class PdaPalletOperationControllerContractTest { + @Test void exposesOnlyDedicatedPdaPalletEndpointsWithCanonicalPickerAuthority() { + RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class); + assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value()); + PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class); + assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value()); + Set paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods()) + .map(Method::getDeclaredAnnotations).flatMap(Arrays::stream) + .filter(PostMapping.class::isInstance).map(PostMapping.class::cast) + .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); + assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java new file mode 100644 index 000000000..59301bc76 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.model.PalletInventoryRow; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +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 org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PdaPalletOperationServiceTest { + @Mock private PdaPalletOperationMapper mapper; + @Mock private InventoryPositionMapper positions; + @Mock private InventoryBalanceMapper balances; + @Mock private InventoryLedgerMapper ledgers; + @Mock private PalletPhysicalGateway physical; + @Mock private PdaWarehouseService history; + private PdaPalletOperationService service; + + @BeforeEach void setUp() { + service = new PdaPalletOperationService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "positions", positions); + ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); + ReflectionTestUtils.setField(service, "physical", physical); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + service.configureTransactions(new NoOpTransactionManager()); + } + + @Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() { + when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1)); + assertThrows(ResponseStatusException.class, () -> service.resolve(identity(), + new PalletOperationRequests.Scan("P-1"))); + verifyNoInteractions(mapper); + } + + @Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() { + var source = pallet(10L, "SOURCE", 88L, 1); + var target = pallet(20L, "TARGET", 88L, 2); + when(physical.resolve("SOURCE")).thenReturn(source); + when(physical.resolve("TARGET")).thenReturn(target); + when(physical.resolveLocation(88L, target.locationId())).thenReturn( + new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(), + "STORAGE", "ACTIVE")); + PalletInventoryRow planned = row(100L, 10L, 1L, 5L); + PalletInventoryRow stale = row(100L, 10L, 2L, 5L); + when(mapper.content("88", 10L)).thenReturn(List.of(planned)); + when(mapper.content("88", 20L)).thenReturn(List.of()); + when(mapper.palletPackages("88", 10L)).thenReturn(5L); + when(mapper.palletPackages("88", 20L)).thenReturn(0L); + when(mapper.lockPosition("88", 100L)).thenReturn(stale); + + AtomicReference operation = new AtomicReference<>(); + doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; }) + .when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get()); + when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + + var request = new PalletOperationRequests.Assemble("idem-1", + new PalletOperationRequests.PalletExpectation("TARGET", 2), + List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)), + List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble"); + + assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request)); + + verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class)); + assertEquals("FAILED_COMPENSATED", operation.get().getStatus()); + verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class)); + verifyNoInteractions(ledgers, history); + } + + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } + private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { + return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, + id == 20L ? "IDLE" : "OCCUPIED", null, version); + } + private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet, + String targetStatus) { + return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(), + pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(), + pallet.version(), pallet.version() + 1, "APPLIED", false); + } + private static PalletInventoryRow row(long positionId, long palletId, long version, long packages) { + PalletInventoryRow value = new PalletInventoryRow(); value.setCustomerId("100"); value.setCustomerName("C"); + value.setBalanceId(200L); value.setPositionId(positionId); value.setInboundCode("IN-1"); + value.setSkuCode("SKU-1"); value.setDestinationWarehouseCode("ONT8"); value.setPalletId(palletId); + value.setPalletNo(palletId == 10L ? "SOURCE" : "TARGET"); value.setBatchNo("DEFAULT"); + value.setWarehouseLocationId(1010L); value.setWarehouseLocationCode("L-1"); + value.setAvailablePackages(packages); value.setHoldPackages(0L); value.setAllocatedPackages(0L); + value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value; + } + + private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager { + @Override protected Object doGetTransaction() { return new Object(); } + @Override protected void doBegin(Object transaction, TransactionDefinition definition) { } + @Override protected void doCommit(DefaultTransactionStatus status) { } + @Override protected void doRollback(DefaultTransactionStatus status) { } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java new file mode 100644 index 000000000..eaef73a37 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.pda; + +import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.Set; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaInventoryOperationContractTest { + @Test + void controllersUseOnlyCanonicalPdaIdentityAndRoutes() { + assertThat(PdaStocktakeController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/pda/v1/last-leg/stocktakes"); + assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/pda/v1/last-leg/inventory-moves"); + assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')"); + } + + @Test + void deviceRequestsCannotSupplyIdentityOrWarehouseScope() { + Set forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorId"); + for (Class type : List.of(PdaStocktakeRequests.Create.class, PdaStocktakeRequests.Count.class, + PdaStocktakeRequests.Submit.class, PdaInventoryMoveRequest.class)) { + Set fields = Stream.of(type.getRecordComponents()).map(component -> component.getName()) + .collect(Collectors.toSet()); + assertThat(fields).doesNotContainAnyElementsOf(forbidden); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java new file mode 100644 index 000000000..c1a5118a4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.pda; + +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PdaOperationsOpenApiContractTest { + private static final Set SERVER_OWNED = Set.of( + "customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName"); + + @Test + void pdaOperationRequestsNeverAcceptServerOwnedScope() { + List> containers = List.of(PdaStocktakeRequests.class, PalletOperationRequests.class, + PdaRelabelRequests.class); + for (Class container : containers) { + for (Class type : container.getDeclaredClasses()) { + if (type.isRecord()) assertNoServerOwnedFields(type); + } + } + assertNoServerOwnedFields(PdaInventoryMoveRequest.class); + } + + @Test + void versionedOpenApiPublishesEveryPdaWarehouseOperation() throws IOException { + String yaml = resource("openapi/overseas-pda-operations-v1.yaml"); + List paths = List.of( + "/api/pda/v1/last-leg/stocktakes:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:", + "/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:", + "/api/pda/v1/last-leg/inventory-moves:", + "/api/pda/v1/last-leg/pallet-operations/pallets/resolve:", + "/api/pda/v1/last-leg/pallet-operations/assemble:", + "/api/pda/v1/last-leg/pallet-operations/disassemble:", + "/api/pda/v1/last-leg/pallet-operations/swap:", + "/api/pda/v1/last-leg/pallet-operations/unbind:", + "/api/pda/v1/last-leg/relabel-tasks/candidates:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:", + "/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:"); + for (String path : paths) assertTrue(yaml.contains(path), path); + assertEquals(19, paths.size()); + assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); + assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); + assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]")); + for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field); + for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) { + assertFalse(yaml.contains(excluded), excluded); + } + } + + private static void assertNoServerOwnedFields(Class type) { + Set fields = Arrays.stream(type.getRecordComponents()) + .map(RecordComponent::getName).collect(Collectors.toSet()); + assertTrue(java.util.Collections.disjoint(fields, SERVER_OWNED), type.getSimpleName()); + } + + private String resource(String name) throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream(name)) { + assertTrue(input != null, name); + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java new file mode 100644 index 000000000..cdb3714ae --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java @@ -0,0 +1,141 @@ +package com.baiyue.lastleg.pda.move.service; + +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; +import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove; +import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +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.ArgumentCaptor; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaInventoryMoveServiceTest { + @Mock PdaInventoryMoveMapper moves; + @Mock InventoryPositionMapper positions; + @Mock InventoryBalanceMapper balances; + @Mock InventoryLedgerMapper ledgers; + @Mock WarehouseResourceGateway warehouse; + @Mock PalletPhysicalGateway physical; + @Mock PdaWarehouseService history; + private PdaInventoryMoveService service; + + @BeforeEach + void setUp() { + service = new PdaInventoryMoveService(); ReflectionTestUtils.setField(service, "moves", moves); + ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "physical", physical); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2)); + when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); + when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); + position.setAvailablePackages(5L); position.setHoldPackages(1L); position.setAllocatedPackages(0L); + position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L); + when(moves.lockPositions("900", 30L)).thenReturn(List.of(position)); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1"); + when(balances.selectById(10L)).thenReturn(balance); + when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"), + eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false)); + + var response = service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")); + + verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L + && value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"), + eq(21L), anyString(), eq("库位优化")); + ArgumentCaptor updated = ArgumentCaptor.forClass(InventoryPosition.class); + verify(positions).updateById(updated.capture()); + assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L); + assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01"); + assertThat(response.packageCount()).isEqualTo(6L); + assertThat(response.palletVersion()).isEqualTo(3); + assertThat(response.status()).isEqualTo("COMPLETED"); + verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class)); + } + + @Test + void rejectsCrossWarehousePalletWithoutMovingResources() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(901L, 20L, 2)); + assertThatThrownBy(() -> service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any()); + } + + @Test + void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3)); + when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); + position.setAvailablePackages(5L); position.setHoldPackages(0L); position.setAllocatedPackages(0L); + position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L); + when(moves.lockPositions("900", 30L)).thenReturn(List.of(position)); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1"); + when(balances.selectById(10L)).thenReturn(balance); + when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"), + eq(21L), anyString(), eq("恢复移库"))).thenReturn(transition(true)); + + var response = service.move(900L, "operator-1", + new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-retry", "恢复移库")); + + verify(physical).apply(eq(900L), argThat(value -> value.locationId() == 20L && value.version() == 2), + eq("OCCUPIED"), eq(21L), anyString(), eq("恢复移库")); + assertThat(response.sourceLocationCode()).isEqualTo("A-01"); + assertThat(response.targetLocationCode()).isEqualTo("B-01"); + verify(positions).updateById(any(InventoryPosition.class)); + } + + private static WarehouseResourceGateway.WarehousePalletReference pallet(long warehouseId, long locationId, int version) { + return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, locationId, + "OCCUPIED", null, version); + } + private static WarehouseResourceGateway.WarehouseLocationReference location(long id, String code) { + return new WarehouseResourceGateway.WarehouseLocationReference(id, code, "STORAGE", BigDecimal.TEN, "OCCUPIED"); + } + private static PalletPhysicalGateway.Transition transition(boolean replayed) { + return new PalletPhysicalGateway.Transition("move-transition", 30L, "PAL-1", 900L, + "OCCUPIED", "OCCUPIED", 20L, 21L, 2, 3, "APPLIED", replayed); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java new file mode 100644 index 000000000..255e8180c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java @@ -0,0 +1,146 @@ +package com.baiyue.lastleg.pda.stocktake.service; + +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem; +import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper; +import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +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.ArgumentCaptor; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaStocktakeServiceTest { + @Mock PdaStocktakeTaskMapper tasks; + @Mock PdaStocktakeItemMapper items; + @Mock PdaStocktakeActionMapper actions; + @Mock PdaStocktakeInventoryMapper inventory; + @Mock InventoryPositionMapper positions; + @Mock InventoryBalanceMapper balances; + @Mock InventoryLedgerMapper ledgers; + @Mock WarehouseResourceGateway warehouse; + @Mock PdaWarehouseService history; + private PdaStocktakeService service; + + @BeforeEach + void setUp() { + service = new PdaStocktakeService(); + ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "actions", actions); ReflectionTestUtils.setField(service, "inventory", inventory); + ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); + ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse); + ReflectionTestUtils.setField(service, "history", history); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + } + + @Test + void createsWarehouseBoundLocationTaskAndFreezesEveryPosition() { + when(warehouse.resolveLocation(900L, "A-01")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference( + 20L, "A-01", "STORAGE", BigDecimal.TEN, "OCCUPIED")); + StocktakeCandidate candidate = new StocktakeCandidate(); candidate.setCustomerId("100"); + candidate.setBalanceId(10L); candidate.setPositionId(11L); candidate.setSkuCode("SKU-1"); + candidate.setPalletId(30L); candidate.setPalletCode("PAL-1"); candidate.setLocationId(20L); + candidate.setLocationCode("A-01"); candidate.setBookPackages(8L); candidate.setPositionVersion(3L); + when(items.candidatesByLocation("900", 20L, "A-01")).thenReturn(List.of(candidate)); + when(items.views(anyLong())).thenReturn(List.of()); + + var response = service.create(900L, "operator-1", new PdaStocktakeRequests.Create( + "location", "A-01", "create-1")); + + ArgumentCaptor task = ArgumentCaptor.forClass(PdaStocktakeTask.class); + ArgumentCaptor item = ArgumentCaptor.forClass(PdaStocktakeItem.class); + verify(tasks).insert(task.capture()); verify(items).insert(item.capture()); + assertThat(task.getValue().getOverseasWarehouseId()).isEqualTo(900L); + assertThat(task.getValue().getScopeType()).isEqualTo("LOCATION"); + assertThat(task.getValue().getCreatedBy()).isEqualTo("operator-1"); + assertThat(item.getValue().getCustomerId()).isEqualTo("100"); + assertThat(item.getValue().getBookPackages()).isEqualTo(8L); + assertThat(item.getValue().getPositionVersion()).isEqualTo(3L); + assertThat(response.task().status()).isEqualTo("IN_PROGRESS"); + } + + @Test + void rejectsPalletFromAnotherWarehouseBeforeReadingInventory() { + when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference( + 30L, "PAL-1", 901L, 20L, "OCCUPIED", null, 2)); + + assertThatThrownBy(() -> service.create(900L, "operator-1", + new PdaStocktakeRequests.Create("PALLET", "PAL-1", "create-1"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(items, never()).candidatesByPallet(anyString(), anyLong()); + } + + @Test + void submitsCountedDifferenceAndWritesBalancedAuditLedger() { + PdaStocktakeTask task = new PdaStocktakeTask(); task.setId(40L); task.setTaskNo("CC-40"); + task.setOverseasWarehouseId(900L); task.setScopeType("PALLET"); task.setScopeCode("PAL-1"); + task.setStatus("IN_PROGRESS"); task.setPositionCount(1); task.setCountedCount(1); task.setDifferenceCount(1); + task.setVersion(2L); + PdaStocktakeTask submitted = new PdaStocktakeTask(); submitted.setId(40L); submitted.setTaskNo("CC-40"); + submitted.setOverseasWarehouseId(900L); submitted.setScopeType("PALLET"); submitted.setScopeCode("PAL-1"); + submitted.setStatus("SUBMITTED"); submitted.setPositionCount(1); submitted.setCountedCount(1); + submitted.setDifferenceCount(1); submitted.setVersion(3L); + when(tasks.find(900L, "CC-40")).thenReturn(task, submitted); + when(tasks.lock(900L, "CC-40")).thenReturn(task); when(tasks.submit(40L, 2L, "operator-1", "复盘确认")).thenReturn(1); + + PdaStocktakeItem item = new PdaStocktakeItem(); item.setId(41L); item.setTaskId(40L); item.setCustomerId("100"); + item.setBalanceId(10L); item.setPositionId(11L); item.setSkuCode("SKU-1"); item.setBookPackages(5L); + item.setActualPackages(7L); item.setDifferencePackages(2L); item.setPositionVersion(3L); item.setVersion(1L); + when(items.lockAll(40L)).thenReturn(List.of(item)); when(items.views(40L)).thenReturn(List.of()); + + InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); + position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); + position.setPalletNo("PAL-1"); position.setAvailablePackages(5L); position.setHoldPackages(0L); + position.setAllocatedPackages(0L); position.setPickedPackages(0L); position.setReceivingPackages(0L); + position.setVersion(3L); + InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("900"); balance.setAvailablePackages(5L); balance.setVersion(8L); + when(inventory.lockPosition(11L, "100", "900")).thenReturn(position); + when(inventory.lockBalance(10L, "100", "900")).thenReturn(balance); + + var response = service.submit(900L, "operator-1", "CC-40", + new PdaStocktakeRequests.Submit(2L, "复盘确认", "submit-1")); + + ArgumentCaptor changedPosition = ArgumentCaptor.forClass(InventoryPosition.class); + ArgumentCaptor changedBalance = ArgumentCaptor.forClass(InventoryBalance.class); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(positions).updateById(changedPosition.capture()); verify(balances).updateById(changedBalance.capture()); + verify(ledgers).insert(ledger.capture()); + assertThat(changedPosition.getValue().getAvailablePackages()).isEqualTo(7L); + assertThat(changedBalance.getValue().getAvailablePackages()).isEqualTo(7L); + assertThat(ledger.getValue().getBeforePackages()).isEqualTo(5L); + assertThat(ledger.getValue().getDeltaPackages()).isEqualTo(2L); + assertThat(ledger.getValue().getAfterPackages()).isEqualTo(7L); + assertThat(ledger.getValue().getBusinessType()).isEqualTo("STOCKTAKE_ADJUST"); + assertThat(response.task().status()).isEqualTo("SUBMITTED"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java new file mode 100644 index 000000000..188b70adf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.relabel.pda.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PdaRelabelContractTest { + @Test + void controllerUsesStablePdaPathAndExactPickerAuthority() { + assertEquals("/api/pda/v1/last-leg/relabel-tasks", + PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]); + assertEquals("hasAuthority('lastleg:pda:operate')", + PdaRelabelController.class.getAnnotation(PreAuthorize.class).value()); + } + + @Test + void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() { + Set forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", + "operatorId", "operatorName", "tenantId"); + for (Class type : PdaRelabelRequests.class.getDeclaredClasses()) { + Set fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName) + .collect(Collectors.toSet()); + assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope"); + } + } + + @Test + void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() { + AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L); + assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId()); + assertThrows(ResponseStatusException.class, + () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L))); + assertThrows(ResponseStatusException.class, + () -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null))); + } + + @Test + void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact", + "ll_relabel_pda_operation", "ll_relabel_pda_history")) { + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table)); + } + assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)")); + assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)")); + int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task"); + int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start); + String block = sql.substring(start, end); + assertFalse(block.contains("dispatch_no")); + assertFalse(block.contains("delivery_no")); + assertFalse(block.contains("outbound_order_no")); + } + + @Test + void everyStateMutationIsTransactional() throws Exception { + Class service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService"); + for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) { + assertTrue(Arrays.stream(service.getDeclaredMethods()) + .filter(candidate -> method.equals(candidate.getName())) + .anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)), + method + " must be transactional"); + } + } + + private static AuthenticatedUser user(Set permissions, Long warehouseId) { + return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA", + permissions, false, null, null, false, warehouseId); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java new file mode 100644 index 000000000..8ee0d9732 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -0,0 +1,245 @@ +package com.baiyue.lastleg.relabel.pda.service; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +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.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PdaRelabelServiceTest { + @Mock RelabelApplicationMapper applications; + @Mock RelabelItemMapper items; + @Mock PdaRelabelTaskMapper tasks; + @Mock PdaRelabelUnitFactMapper unitFacts; + @Mock PdaRelabelOperationMapper operations; + @Mock PdaRelabelHistoryMapper histories; + @Mock RelabelApplicationService applicationService; + @Mock RelabelPhotoFileGateway photoFiles; + PdaRelabelService service; + RelabelApplication application; + RelabelItem item; + List persistedFacts; + + @BeforeEach + void setUp() { + service = new PdaRelabelService(); + ReflectionTestUtils.setField(service, "applications", applications); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "tasks", tasks); + ReflectionTestUtils.setField(service, "unitFacts", unitFacts); + ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "histories", histories); + ReflectionTestUtils.setField(service, "applicationService", applicationService); + ReflectionTestUtils.setField(service, "photoFiles", photoFiles); + ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper()); + service.initializeObjectMapper(); + application = application("PENDING_PROCESSING"); + item = item(); + persistedFacts = new ArrayList<>(); + lenient().when(operations.find(anyString(), anyString())).thenReturn(null); + lenient().when(items.selectList(any())).thenReturn(List.of(item)); + lenient().when(histories.selectList(any())).thenReturn(List.of()); + lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts)); + lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; }); + lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1); + } + + @Test + void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() { + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(null); + ArgumentCaptor task = ArgumentCaptor.forClass(PdaRelabelTask.class); + + var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")); + + verify(tasks).insert(task.capture()); + assertEquals(900L, task.getValue().getOverseasWarehouseId()); + assertEquals("100", task.getValue().getCustomerId()); + assertEquals(PdaRelabelService.PROCESSING, result.taskStatus()); + verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class)); + } + + @Test + void crossWarehouseClaimIsHiddenAndCannotCreateTask() { + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"))); + assertEquals(404, error.getStatusCode().value()); + verify(tasks, never()).insert(any(PdaRelabelTask.class)); + } + + @Test + void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + when(unitFacts.findOldLabel("BOX-1")).thenReturn(null); + ArgumentCaptor fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class); + + var result = service.scan("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1")); + + verify(unitFacts).insert(fact.capture()); + assertEquals("RELABELLED", fact.getValue().getResult()); + assertEquals("BOX-1", fact.getValue().getOldLabel()); + assertEquals(1, result.relabelledPackages()); + assertEquals(1, result.version()); + } + + @Test + void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 1); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale"))); + verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class)); + } + + @Test + void oldLabelAlreadyAttributedToAnotherTaskIsRejected() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + PdaRelabelUnitFact otherTaskFact = fact("RELABELLED"); + otherTaskFact.setTaskId(99L); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + when(items.selectOne(any())).thenReturn(item); + when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact); + + assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task"))); + verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class)); + } + + @Test + void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 1); + task.setRelabelledPackages(1); + persistedFacts.add(fact("RELABELLED")); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + var result = service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done")); + + verify(photoFiles).validate("100", 20L, 501L); + assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus()); + assertEquals("PROCESSING", result.applicationStatus()); + verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); + } + + @Test + void approvalUsesExistingApplicationCompletionAndReworkDoesNot() { + application.setStatus("PROCESSING"); + PdaRelabelTask approved = task("WAIT_REVIEW", 2); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(approved); + + var result = service.review("8", "reviewer", 900L, "FBR-1", + new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok")); + + assertEquals("COMPLETED", result.taskStatus()); + assertEquals("COMPLETED", result.applicationStatus()); + verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class)); + } + + @Test + void reviewCanReturnTaskForReworkWithoutCompletingApplication() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("WAIT_REVIEW", 2); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + var result = service.review("8", "reviewer", 900L, "FBR-1", + new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label")); + + assertEquals("PROCESSING", result.taskStatus()); + assertEquals("PROCESSING", result.applicationStatus()); + verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any()); + } + + @Test + void completionRejectsMissingPackageFactsBeforePhotoValidation() { + application.setStatus("PROCESSING"); + PdaRelabelTask task = task("PROCESSING", 0); + when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application); + when(tasks.lock("100", 1L)).thenReturn(task); + + assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1", + new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null))); + verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong()); + } + + private RelabelApplication application(String status) { + RelabelApplication value = new RelabelApplication(); + value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1"); + value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900"); + value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1); + value.setLabelDeadline(LocalDateTime.now().plusDays(1)); + return value; + } + + private RelabelItem item() { + RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100"); + value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product"); + value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value; + } + + private PdaRelabelTask task(String status, int version) { + PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100"); + value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status); + value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1); + value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0); + value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value; + } + + private PdaRelabelUnitFact fact(String result) { + PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L); + value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100"); + value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result); + value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value; + } +} diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 24a9d2c14..81332bff9 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3614,6 +3614,41 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_business_binding' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_business_binding MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND COALESCE(table_comment,'') <> '实物托盘状态幂等变更与补偿记录'), 'ALTER TABLE wh_pallet_operation COMMENT = ''实物托盘状态幂等变更与补偿记录''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='id' AND COALESCE(column_comment,'') <> '托盘状态操作ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN id BIGINT NOT NULL COMMENT ''托盘状态操作ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_key' AND COALESCE(column_comment,'') <> '跨服务幂等操作键'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_key VARCHAR(128) NOT NULL COMMENT ''跨服务幂等操作键''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='request_hash' AND COALESCE(column_comment,'') <> '状态变更请求摘要'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN request_hash VARCHAR(128) NOT NULL COMMENT ''状态变更请求摘要''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='warehouse_id' AND COALESCE(column_comment,'') <> '实际海外仓ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN warehouse_id BIGINT NOT NULL COMMENT ''实际海外仓ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_id' AND COALESCE(column_comment,'') <> '实物托盘ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_id BIGINT NOT NULL COMMENT ''实物托盘ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='pallet_code' AND COALESCE(column_comment,'') <> '实物托盘码快照'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''实物托盘码快照''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_status' AND COALESCE(column_comment,'') <> '变更前托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_status VARCHAR(20) NOT NULL COMMENT ''变更前托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_status' AND COALESCE(column_comment,'') <> '变更后托盘状态'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_status VARCHAR(20) NOT NULL COMMENT ''变更后托盘状态''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_location_id' AND COALESCE(column_comment,'') <> '变更前库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_location_id BIGINT NOT NULL COMMENT ''变更前库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_location_id' AND COALESCE(column_comment,'') <> '变更后库位ID'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_location_id BIGINT NOT NULL COMMENT ''变更后库位ID''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='before_version' AND COALESCE(column_comment,'') <> '变更前托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN before_version INT NOT NULL COMMENT ''变更前托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='after_version' AND COALESCE(column_comment,'') <> '变更后托盘版本'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN after_version INT NOT NULL COMMENT ''变更后托盘版本''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='operation_status' AND COALESCE(column_comment,'') <> '操作状态:APPLIED已应用、COMPENSATED已补偿'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN operation_status VARCHAR(32) NOT NULL COMMENT ''操作状态:APPLIED已应用、COMPENSATED已补偿''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='reason' AND COALESCE(column_comment,'') <> '操作原因'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN reason VARCHAR(500) NULL COMMENT ''操作原因''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='created_at' AND COALESCE(column_comment,'') <> '创建时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_pallet_operation' AND column_name='updated_at' AND COALESCE(column_comment,'') <> '更新时间'), 'ALTER TABLE wh_pallet_operation MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT ''更新时间''', 'SELECT 1')); +PREPARE schema_comments_025 FROM @ddl; EXECUTE schema_comments_025; DEALLOCATE PREPARE schema_comments_025; -- ============================================================================ -- 2026-08-19 / 026 -- Separate tail-leg main workflow status from child-document status diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java new file mode 100644 index 000000000..37ffad011 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java @@ -0,0 +1,39 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehousePalletTransitionService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/pda-pallet-transitions") +public class InternalPdaPalletTransitionController { + @Resource private InternalServiceTokenGuard tokenGuard; + @Resource private WarehousePalletTransitionService service; + + @PostMapping("/{palletId}") + public ApiResponse apply( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @Valid @RequestBody PalletTransitionRequests.Apply request) { + tokenGuard.verify(token); + return ApiResponse.success(service.apply(palletId, request)); + } + + @PostMapping("/{palletId}/{operationKey}/compensate") + public ApiResponse compensate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long palletId, @PathVariable String operationKey, + @Valid @RequestBody PalletTransitionRequests.Compensate request) { + tokenGuard.verify(token); + return ApiResponse.success(service.compensate(palletId, operationKey, request)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java index a5e66727a..1d651fc2d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletController.java @@ -31,4 +31,5 @@ public class InternalWarehousePalletController { tokenGuard.verify(token); return ApiResponse.success(service.resolvePublicPallet(palletCode)); } + } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java new file mode 100644 index 000000000..9d8bdc683 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionRequests.java @@ -0,0 +1,20 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +public final class PalletTransitionRequests { + private PalletTransitionRequests() { } + + public record Apply(@Positive long warehouseId, + @PositiveOrZero int expectedVersion, + @NotBlank @Size(max = 20) String targetStatus, + @NotNull @Positive Long targetLocationId, + @NotBlank @Size(max = 128) String operationKey, + @Size(max = 500) String reason) { } + + public record Compensate(@Positive long warehouseId) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java new file mode 100644 index 000000000..c3e159661 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletTransitionResponse.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.dto; + +public record PalletTransitionResponse( + String operationKey, + long palletId, + String palletCode, + long warehouseId, + String beforeStatus, + String afterStatus, + long beforeLocationId, + long afterLocationId, + int beforeVersion, + int afterVersion, + String operationStatus, + boolean replayed +) { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java new file mode 100644 index 000000000..f9bfbc36e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.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("wh_pallet_operation") +public class WarehousePalletOperationEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String operationKey; + private String requestHash; + private Long warehouseId; + private Long palletId; + private String palletCode; + private String beforeStatus; + private String afterStatus; + private Long beforeLocationId; + private Long afterLocationId; + private Integer beforeVersion; + private Integer afterVersion; + private String operationStatus; + private String reason; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java index 5e2f5ca0d..fb225f1f0 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletMapper.java @@ -3,5 +3,4 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehousePalletEntity; -public interface WarehousePalletMapper extends BaseMapper { -} +public interface WarehousePalletMapper extends BaseMapper { } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java new file mode 100644 index 000000000..751a848c0 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +public interface WarehousePalletTransitionMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1") + WarehousePalletOperationEntity findOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet_operation WHERE operation_key=#{operationKey} LIMIT 1 FOR UPDATE") + WarehousePalletOperationEntity lockOperation(@Param("operationKey") String operationKey); + + @Select("SELECT * FROM wh_pallet WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND deleted=0 LIMIT 1 FOR UPDATE") + WarehousePalletEntity lockPublicPallet(@Param("warehouseId") long warehouseId, + @Param("palletId") long palletId); + + @Select("SELECT COUNT(*) FROM wh_location WHERE id=#{locationId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND status<>'INACTIVE' AND deleted=0") + long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); + + @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, + @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, + @Param("expectedVersion") int expectedVersion); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java new file mode 100644 index 000000000..bf2782ec6 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -0,0 +1,106 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.dto.PalletTransitionResponse; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Set; + +@Service +public class WarehousePalletTransitionService { + private static final Set MUTABLE_STATUSES = Set.of("IDLE", "OCCUPIED"); + @Resource private WarehousePalletTransitionMapper mapper; + + @Transactional + public PalletTransitionResponse apply(long palletId, PalletTransitionRequests.Apply request) { + String key = request.operationKey().trim(); + String status = request.targetStatus().trim().toUpperCase(); + if (!MUTABLE_STATUSES.contains(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "托盘作业状态只允许 IDLE 或 OCCUPIED"); + } + String requestHash = hash(request.warehouseId() + "|" + palletId + "|" + request.expectedVersion() + + "|" + status + "|" + request.targetLocationId()); + WarehousePalletOperationEntity existing = mapper.findOperation(key); + if (existing != null) return replay(existing, requestHash); + + WarehousePalletEntity pallet = mapper.lockPublicPallet(request.warehouseId(), palletId); + if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "当前冻结仓内托盘不存在"); + int version = pallet.getVersion() == null ? 0 : pallet.getVersion(); + if (version != request.expectedVersion()) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + if (mapper.countActiveLocation(request.warehouseId(), request.targetLocationId()) != 1) { + throw new BizException(ResultCode.CONFLICT, "目标库位无效或已停用"); + } + if (pallet.getLocationId() == null) { + throw new BizException(ResultCode.CONFLICT, "托盘没有有效库位,不能执行库内作业"); + } + if (mapper.transition(request.warehouseId(), palletId, status, request.targetLocationId(), version) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘版本已变化,请重新扫码"); + } + WarehousePalletOperationEntity operation = new WarehousePalletOperationEntity(); + operation.setId(IdWorker.getId()); operation.setOperationKey(key); operation.setRequestHash(requestHash); + operation.setWarehouseId(request.warehouseId()); operation.setPalletId(palletId); + operation.setPalletCode(pallet.getPalletCode()); operation.setBeforeStatus(pallet.getStatus()); + operation.setAfterStatus(status); operation.setBeforeLocationId(pallet.getLocationId()); + operation.setAfterLocationId(request.targetLocationId()); operation.setBeforeVersion(version); + operation.setAfterVersion(version + 1); operation.setOperationStatus("APPLIED"); + operation.setReason(trim(request.reason())); operation.setCreatedAt(LocalDateTime.now()); + operation.setUpdatedAt(operation.getCreatedAt()); mapper.insert(operation); + return response(operation, false); + } + + @Transactional + public PalletTransitionResponse compensate(long palletId, String operationKey, + PalletTransitionRequests.Compensate request) { + WarehousePalletOperationEntity operation = mapper.lockOperation(operationKey); + if (operation == null || !Long.valueOf(palletId).equals(operation.getPalletId()) + || !Long.valueOf(request.warehouseId()).equals(operation.getWarehouseId())) { + throw new BizException(ResultCode.NOT_FOUND, "托盘状态变更记录不存在"); + } + if ("COMPENSATED".equals(operation.getOperationStatus())) return response(operation, true); + if (!"APPLIED".equals(operation.getOperationStatus())) { + throw new BizException(ResultCode.CONFLICT, "托盘状态变更不可补偿"); + } + if (mapper.transition(request.warehouseId(), palletId, operation.getBeforeStatus(), + operation.getBeforeLocationId(), operation.getAfterVersion()) != 1) { + throw new BizException(ResultCode.CONFLICT, "托盘已被后续作业修改,自动补偿失败"); + } + operation.setOperationStatus("COMPENSATED"); operation.setUpdatedAt(LocalDateTime.now()); + mapper.updateById(operation); + return response(operation, false); + } + + private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { + if (!hash.equals(operation.getRequestHash())) { + throw new BizException(ResultCode.CONFLICT, "操作幂等键已被不同托盘请求使用"); + } + return response(operation, true); + } + private static PalletTransitionResponse response(WarehousePalletOperationEntity value, boolean replayed) { + return new PalletTransitionResponse(value.getOperationKey(), value.getPalletId(), value.getPalletCode(), + value.getWarehouseId(), value.getBeforeStatus(), value.getAfterStatus(), value.getBeforeLocationId(), + value.getAfterLocationId(), value.getBeforeVersion(), value.getAfterVersion(), + value.getOperationStatus(), replayed); + } + private static String hash(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } +} diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 9eb4d8dcd..7665093d0 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -178,6 +178,20 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding ( UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id) ) ENGINE=InnoDB COMMENT='托盘业务绑定关系'; +CREATE TABLE IF NOT EXISTS wh_pallet_operation ( + id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', + request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + pallet_id BIGINT NOT NULL COMMENT '实物托盘ID', pallet_code VARCHAR(128) NOT NULL COMMENT '实物托盘码快照', + before_status VARCHAR(20) NOT NULL COMMENT '变更前托盘状态', after_status VARCHAR(20) NOT NULL COMMENT '变更后托盘状态', + before_location_id BIGINT NOT NULL COMMENT '变更前库位ID', after_location_id BIGINT NOT NULL COMMENT '变更后库位ID', + before_version INT NOT NULL COMMENT '变更前托盘版本', after_version INT NOT NULL COMMENT '变更后托盘版本', + operation_status VARCHAR(32) NOT NULL COMMENT '操作状态:APPLIED已应用、COMPENSATED已补偿', + reason VARCHAR(500) 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_wh_pallet_operation_key (operation_key), + KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) +) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; + -- Every warehouse has one non-occupiable virtual location used for inventory history. INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type, max_load_kg, status, remark) diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index f59b0735b..678cdbb21 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List tables = parseAndAssert(baseline, "仓储基线 SQL"); - assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); + assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移"); parseAndAssert(incremental, "统一增量 SQL"); int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments"); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java new file mode 100644 index 000000000..5f998e23c --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletTransitionRequests; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehousePalletOperationEntity; +import com.baiyue.warehouse.mapper.WarehousePalletTransitionMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WarehousePalletTransitionServiceTest { + @Mock private WarehousePalletTransitionMapper mapper; + private WarehousePalletTransitionService service; + + @BeforeEach void setUp() { + service = new WarehousePalletTransitionService(); + ReflectionTestUtils.setField(service, "mapper", mapper); + } + + @Test void appliesVersionedPublicPalletTransitionAndPersistsCompensationReceipt() { + WarehousePalletEntity pallet = pallet("P-1", "IDLE", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.countActiveLocation(88L, 20L)).thenReturn(1L); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "assemble")); + + assertEquals("APPLIED", response.operationStatus()); + assertEquals(5, response.afterVersion()); + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletOperationEntity.class); + verify(mapper).insert(captor.capture()); + assertEquals("IDLE", captor.getValue().getBeforeStatus()); + assertEquals("OCCUPIED", captor.getValue().getAfterStatus()); + assertEquals(4, captor.getValue().getBeforeVersion()); + } + + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.findOperation("operation-1")).thenReturn(existing); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", "any changed reason")); + + assertTrue(response.replayed()); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + @Test void compensationRequiresExactAppliedVersionAndRestoresBeforeState() { + WarehousePalletOperationEntity existing = operation(); + when(mapper.lockOperation("operation-1")).thenReturn(existing); + when(mapper.transition(88L, 10L, "IDLE", 11L, 5)).thenReturn(1); + + var response = service.compensate(10L, "operation-1", new PalletTransitionRequests.Compensate(88L)); + + assertEquals("COMPENSATED", response.operationStatus()); + verify(mapper).updateById(existing); + } + + @Test void staleVersionIsRejectedWithoutPhysicalMutation() { + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-1", "IDLE", 5)); + assertThrows(BizException.class, () -> service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "OCCUPIED", 20L, "operation-1", null))); + verify(mapper, never()).transition(anyLong(), anyLong(), anyString(), anyLong(), anyInt()); + } + + private static WarehousePalletEntity pallet(String code, String status, int version) { + WarehousePalletEntity value = new WarehousePalletEntity(); value.setId(10L); value.setCustomerId(0L); + value.setWarehouseId(88L); value.setLocationId(11L); value.setPalletCode(code); + value.setStatus(status); value.setVersion(version); return value; + } + private static WarehousePalletOperationEntity operation() { + WarehousePalletOperationEntity value = new WarehousePalletOperationEntity(); value.setId(1L); + value.setOperationKey("operation-1"); + value.setRequestHash("f17b4386e6fdecbaab1d066ae4fefa47dba0687ff27361b8f92ef097d4e8072e"); + value.setWarehouseId(88L); value.setPalletId(10L); value.setPalletCode("P-1"); + value.setBeforeStatus("IDLE"); value.setAfterStatus("OCCUPIED"); value.setBeforeLocationId(11L); + value.setAfterLocationId(20L); value.setBeforeVersion(4); value.setAfterVersion(5); + value.setOperationStatus("APPLIED"); return value; + } +} From 68bdc3e6bbeea430456e03f70c70e2756cbf0cca Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:25:13 +0800 Subject: [PATCH 152/231] test: cover overseas pda warehouse operation lifecycles --- scripts/admin-test-env-acceptance.mjs | 23 +- scripts/admin-test-env-modules/README.md | 6 +- ...-overseas-pda-operations-contract.test.mjs | 88 ++++ .../phase4-overseas-pda-operations.mjs | 442 ++++++++++++++++++ .../phase4-overseas-pda.mjs | 44 +- ...verseas-acceptance-entry-contract.test.mjs | 4 +- 6 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0c3a005ff..ea82e9925 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ ['orders', ['merchant-qualification-fixture']], @@ -31,11 +31,12 @@ const MODULE_DEPENDENCIES = new Map([ ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries']], ['phase4-overseas-pda', ['phase4-carrier-pickup-ready']], - ['phase4-overseas-inbound-operations', ['phase4-overseas-pda']], - ['phase4-overseas-client-services', ['phase4-overseas-pda']], - ['phase4-overseas-dispatch', ['phase4-overseas-pda']], - ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda']], - ['phase4-carrier-nodes', ['phase4-overseas-pda']], + ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], + ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], + ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], + ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], + ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], + ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], ]) function stableTopologicalOrder(modules) { @@ -119,10 +120,14 @@ export function normalizeModuleOrder(modules) { } for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch']) { - if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda')) { - normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda') + if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda-operations')) { + normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda-operations') } } + if (normalized.includes('phase4-overseas-pda') + && !normalized.includes('phase4-overseas-pda-operations')) { + normalized.splice(normalized.indexOf('phase4-overseas-pda') + 1, 0, 'phase4-overseas-pda-operations') + } if (normalized.includes('phase4-overseas-pda')) { const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-loading-finalize'] @@ -133,7 +138,7 @@ export function normalizeModuleOrder(modules) { if (normalized.includes('phase4-cross-region-events')) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', - 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda'] + 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda-operations'] const targetIndex = normalized.indexOf('phase4-cross-region-events') const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 80a224f18..30398eadd 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,7 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | -| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` | +| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | @@ -64,10 +64,10 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;PDA 收货模块使用本轮临时 WAREHOUSE_PICKER 海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID。收货成功后由相邻的 `phase4-overseas-pda-operations` 消费仅存在于进程内存、不会进入 JSON 报告的 Token 与库存事实,执行盘点、整托移库、托盘操作和换标;新模块无论成功失败都在 finally 停用两个 picker 并清空临时凭据。两模块均不调用出库、退货出库、派送或内部接口;任何真实柜、箱唛、托盘、库存或换标前置缺失都必须形成 deployment blocker,不能按空场景通过;船节点禁止手工创建任务。 ```powershell -node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes +node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-overseas-pda-operations,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes ``` 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs new file mode 100644 index 000000000..844fecbb7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { run } from './phase4-overseas-pda-operations.mjs' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda-operations.mjs'), 'utf8') +const receivingSource = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') +const entrySource = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8') + +test('PDA operations run immediately after receiving and all later consumers depend on cleanup completion', () => { + const modules = normalizeModuleOrder(['phase4-overseas-client-services']) + const receiving = modules.indexOf('phase4-overseas-pda') + const operations = modules.indexOf('phase4-overseas-pda-operations') + const consumer = modules.indexOf('phase4-overseas-client-services') + assert.ok(receiving >= 0 && operations === receiving + 1 && consumer > operations) + assert.match(entrySource, /'phase4-overseas-pda-operations', \['phase4-overseas-pda'\]/) + for (const module of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', + 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-carrier-nodes']) { + assert.match(entrySource, new RegExp(`'${module}', \\[[^\\]]*'phase4-overseas-pda-operations'`)) + } +}) + +test('receiving hands off only transient credentials and completed warehouse facts', () => { + assert.match(receivingSource, /context\.context\.phase4OverseasPdaOperations = \{/) + assert.match(receivingSource, /completedFixtures: \[\]/) + assert.match(receivingSource, /if \(!handoffReady\) \{[\s\S]*await cleanup\(\)/) + assert.doesNotMatch(receivingSource, /createdResources\.phase4OverseasPdaOperations/) + assert.doesNotMatch(receivingSource, /primary: primary \? \{[^}]*password/) + assert.doesNotMatch(entrySource.match(/const reportSurface = JSON\.stringify\(\{([\s\S]*?)\n \}\)/)?.[1] || '', + /context/) +}) + +test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries without outbound domains', () => { + for (const endpoint of [ + '/api/pda/v1/last-leg/stocktakes', + '/api/pda/v1/last-leg/inventory-moves', + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', + '/swap', '/disassemble', '/assemble', '/unbind', + '/api/pda/v1/last-leg/relabel-tasks', + '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review', + ]) assert.ok(source.includes(endpoint), endpoint) + for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', + 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED']) { + assert.ok(source.includes(marker), marker) + } + for (const forbidden of ['/outbound', '/returns/outbound', '/dispatch', '/delivery', '/internal/']) { + assert.equal(source.includes(forbidden), false, forbidden) + } + assert.doesNotMatch(source, /fetch\(|https?:\/\//) + assert.match(source, /requestId=/) + assert.match(source, /traceId=/) +}) + +test('fixture failure still disables both picker accounts and removes all transient tokens', async () => { + const cleanups = [] + const gaps = [] + const steps = [] + const handoff = { + marker: 'PDAOPS-CONTRACT', + primary: { membershipId: '1', username: 'primary', token: 'primary-secret-token' }, + secondary: { membershipId: '2', username: 'secondary', token: 'secondary-secret-token' }, + completedFixtures: [], + async cleanup() { cleanups.push('secondary', 'primary') }, + } + const context = { + marker: 'PDAOPS-CONTRACT', context: { phase4OverseasPdaOperations: handoff }, + createdResources: {}, + addCoverageGap(...args) { gaps.push(args) }, + assert(module, name, response, passed, evidence, options) { + steps.push({ module, name, passed, evidence, options }) + }, + } + + await run(context) + + assert.deepEqual(cleanups, ['secondary', 'primary']) + assert.equal(handoff.primary.token, undefined) + assert.equal(handoff.secondary.token, undefined) + assert.equal(context.context.phase4OverseasPdaOperations, undefined) + assert.ok(gaps.some((gap) => gap[2] === 'P2-P5本轮PDA收货交接')) + assert.ok(steps.some((step) => !step.passed && step.options?.deploymentBlocker)) + assert.equal(JSON.stringify(context).includes('secret-token'), false) +}) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs new file mode 100644 index 000000000..28696796e --- /dev/null +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -0,0 +1,442 @@ +const MODULE = 'phase4-overseas-pda-operations' +const SCENARIO = 'phase4-overseas-pda-p2-p5' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function conflict(response) { + return (response?.http === 409 || (response?.http === 200 + && ['409', 'A0409'].includes(String(response?.body?.code)))) + && nonBlank(response?.requestId) && nonBlank(response?.traceId) +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function records(value) { return Array.isArray(value?.records) ? value.records : Array.isArray(value) ? value : [] } +function key(context, action) { + return `${context.marker}-PDAOPS-${action}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function operationPallet(result, palletCode) { + return (result?.pallets || []).find((item) => item?.palletCode === palletCode) +} + +function blocker(context, name, reason, response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) +} + +async function request(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return { response, data: success(response) ? response.body?.data : null } +} + +async function expectConflict(context, token, method, name, path, body) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, conflict(response), trace(response), OPTIONS) + return response +} + +async function resolvePallet(context, token, palletCode, name = '扫码解析托盘') { + return request(context, token, 'POST', name, + '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) +} + +async function createLocation(context, warehouseId, suffix) { + const locationCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-64) + const response = await context.requestOverseas('POST', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: 'PDAOPS', locationCode, locationType: 'STORAGE', maxLoadKg: 5000, + status: 'IDLE', remark: `${context.marker}-pda-operations-${suffix}`, + }) + const locationId = id(response?.body?.data) + context.addStep(MODULE, `创建${suffix}目标库位`, response, + success(response) && /^\d+$/.test(locationId) && locationId !== '0', + `locationId=${locationId || 'missing'}, locationCode=${locationCode}; ${trace(response)}`, OPTIONS) + return success(response) ? { locationId, locationCode } : null +} + +async function createEmptyPallet(context, token, fixture, suffix, destinationWarehouseCode) { + const palletCode = `PDAOPS-${context.marker}-${suffix}`.replace(/[^A-Za-z0-9_-]/g, '').slice(-120) + const response = await context.requestOverseas('POST', + `/api/platform/v1/warehouse-admin/${encodeURIComponent(fixture.overseasWarehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', + locationId: fixture.location.locationId, + fbaWarehouseCode: destinationWarehouseCode || null, + remark: `${context.marker}-pda-operations-${suffix}`, + }) + context.addStep(MODULE, `创建${suffix}辅助空托盘`, response, success(response), trace(response), OPTIONS) + if (!success(response)) return null + return (await resolvePallet(context, token, palletCode, `解析${suffix}辅助空托盘`)).data +} + +async function verifyStocktake(context, token, fixture) { + const path = '/api/pda/v1/last-leg/stocktakes' + const body = { scopeType: 'PALLET', scopeCode: fixture.pallet.palletCode, + idempotencyKey: key(context, 'STOCKTAKE-CREATE') } + const created = await request(context, token, 'POST', 'P2按PALLET创建盘点任务', path, body) + const taskNo = id(created.data?.task?.taskNo) + if (!taskNo || !Array.isArray(created.data?.items) || created.data.items.length === 0) { + blocker(context, 'P2真实库存盘点', '本轮完成托盘没有可盘点库存位置', created.response) + return false + } + const replay = await request(context, token, 'POST', 'P2创建盘点同键重放', path, body) + context.assert(MODULE, 'P2创建重放复用同一任务', replay.response, + id(replay.data?.task?.taskNo) === taskNo, trace(replay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P2创建盘点同键异载荷A0409', path, + { ...body, scopeCode: `${body.scopeCode}-DIFFERENT` }) + + for (const [index, item] of created.data.items.entries()) { + const countPath = `${path}/${encodeURIComponent(taskNo)}/counts` + const countBody = { positionId: id(item.positionId), actualPackages: Number(item.bookPackages), + expectedVersion: Number(item.version), idempotencyKey: key(context, `STOCKTAKE-COUNT-${index}`) } + const counted = await request(context, token, 'POST', `P2库存项${index + 1}按原数计数`, countPath, countBody) + context.assert(MODULE, `P2库存项${index + 1}零差异`, counted.response, + Number(counted.data?.actualPackages) === Number(item.bookPackages) + && Number(counted.data?.differencePackages) === 0, + `positionId=${id(item.positionId)}, book=${item.bookPackages}; ${trace(counted.response)}`, OPTIONS) + const countReplay = await request(context, token, 'POST', `P2库存项${index + 1}计数同键重放`, countPath, countBody) + context.assert(MODULE, `P2库存项${index + 1}重放结果稳定`, countReplay.response, + id(countReplay.data?.positionId) === id(item.positionId) + && Number(countReplay.data?.actualPackages) === Number(item.bookPackages), trace(countReplay.response), OPTIONS) + await expectConflict(context, token, 'POST', `P2库存项${index + 1}计数同键异载荷A0409`, countPath, + { ...countBody, actualPackages: Number(item.bookPackages) + 1 }) + await expectConflict(context, token, 'POST', `P2库存项${index + 1}旧版本A0409`, countPath, + { ...countBody, idempotencyKey: key(context, `STOCKTAKE-COUNT-STALE-${index}`) }) + } + + const detail = await request(context, token, 'GET', 'P2回查全部盘点项已计数', + `${path}/${encodeURIComponent(taskNo)}`) + const taskVersion = Number(detail.data?.task?.version) + context.assert(MODULE, 'P2盘点任务计数守恒', detail.response, + detail.data?.task?.status === 'IN_PROGRESS' + && Number(detail.data?.task?.countedCount) === detail.data?.items?.length + && detail.data.items.every((item) => Number(item.actualPackages) === Number(item.bookPackages)), + `counted=${detail.data?.task?.countedCount ?? 'missing'}, items=${detail.data?.items?.length ?? 'missing'}; ${trace(detail.response)}`, OPTIONS) + await expectConflict(context, token, 'POST', 'P2提交盘点旧任务版本A0409', + `${path}/${encodeURIComponent(taskNo)}/submit`, { + expectedVersion: Math.max(0, taskVersion - 1), reason: `${context.marker}-stale`, + idempotencyKey: key(context, 'STOCKTAKE-SUBMIT-STALE'), + }) + const submitBody = { expectedVersion: taskVersion, reason: `${context.marker}-matched`, + idempotencyKey: key(context, 'STOCKTAKE-SUBMIT') } + const submitted = await request(context, token, 'POST', 'P2提交盘点到SUBMITTED', + `${path}/${encodeURIComponent(taskNo)}/submit`, submitBody) + context.assert(MODULE, 'P2盘点提交状态正确且无差异', submitted.response, + submitted.data?.task?.status === 'SUBMITTED' && Number(submitted.data?.task?.differenceCount) === 0, + trace(submitted.response), OPTIONS) + const submitReplay = await request(context, token, 'POST', 'P2提交盘点同键重放', + `${path}/${encodeURIComponent(taskNo)}/submit`, submitBody) + context.assert(MODULE, 'P2提交重放仍为SUBMITTED', submitReplay.response, + submitReplay.data?.task?.status === 'SUBMITTED', trace(submitReplay.response), OPTIONS) + const page = await request(context, token, 'GET', 'P2分页查询已提交盘点', + `${path}?page=1&size=20&status=SUBMITTED`) + context.assert(MODULE, 'P2列表和详情均包含本轮任务', page.response, + records(page.data).some((item) => id(item?.taskNo) === taskNo), + `taskNo=${taskNo}, records=${records(page.data).length}; ${trace(page.response)}`, OPTIONS) + return success(submitted.response) +} + +async function verifyInventoryMove(context, token, fixture) { + const target = await createLocation(context, fixture.overseasWarehouseId, 'MOVE') + if (!target) return false + let pallet = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P3移库前解析整托')).data + if (!pallet || !nonBlank(pallet.locationCode)) { + blocker(context, 'P3整托移库', '本轮完成托盘无法解析当前位置') + return false + } + const originalLocationCode = pallet.locationCode + const path = '/api/pda/v1/last-leg/inventory-moves' + const body = { sourcePalletCode: pallet.palletCode, targetLocationCode: target.locationCode, + expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-FORWARD'), + reason: `${context.marker}-move-forward` } + const moved = await request(context, token, 'POST', 'P3整托移到目标库位', path, body) + context.assert(MODULE, 'P3整托移库数量和目标位置正确', moved.response, + moved.data?.status === 'COMPLETED' && moved.data?.targetLocationCode === target.locationCode + && Number(moved.data?.packageCount) > 0, trace(moved.response), OPTIONS) + const replay = await request(context, token, 'POST', 'P3整托移库同键重放', path, body) + context.assert(MODULE, 'P3移库重放标记且结果稳定', replay.response, + replay.data?.replayed === true && replay.data?.moveNo === moved.data?.moveNo, trace(replay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P3移库同键异载荷A0409', path, + { ...body, reason: `${body.reason}-DIFFERENT` }) + await expectConflict(context, token, 'POST', 'P3移库旧托盘版本A0409', path, { + ...body, targetLocationCode: originalLocationCode, idempotencyKey: key(context, 'MOVE-STALE'), + }) + pallet = (await resolvePallet(context, token, pallet.palletCode, 'P3回查托盘已位于目标库位')).data + context.assert(MODULE, 'P3托盘与库存位置同步到目标库位', moved.response, + pallet?.locationCode === target.locationCode && Number(pallet?.totalPackages) > 0, + `location=${pallet?.locationCode || 'missing'}; ${trace(moved.response)}`, OPTIONS) + const restoreBody = { sourcePalletCode: pallet.palletCode, targetLocationCode: originalLocationCode, + expectedPalletVersion: Number(pallet.palletVersion), idempotencyKey: key(context, 'MOVE-RESTORE'), + reason: `${context.marker}-move-restore` } + const restored = await request(context, token, 'POST', 'P3整托移回原库位供后续验证', path, restoreBody) + return success(restored.response) && restored.data?.targetLocationCode === originalLocationCode +} + +async function replayOperation(context, token, name, path, body, expectedType) { + const first = await request(context, token, 'POST', name, path, body) + const replay = await request(context, token, 'POST', `${name}同键重放`, path, body) + context.assert(MODULE, `${name}重放结果稳定`, replay.response, + replay.data?.replayed === true && replay.data?.operationId === first.data?.operationId + && replay.data?.operationType === expectedType, + trace(replay.response), OPTIONS) + return first +} + +async function verifyPalletOperations(context, token, fixture) { + const base = '/api/pda/v1/last-leg/pallet-operations' + let main = (await resolvePallet(context, token, fixture.pallet.palletCode, 'P4 resolve主托盘')).data + if (!main || !Array.isArray(main.inventory) || main.inventory.length === 0) { + blocker(context, 'P4托盘操作', '主托盘没有可移动库存行') + return false + } + if (main.inventory.reduce((total, line) => total + Number(line.availablePackages || 0), 0) <= 1) { + blocker(context, 'P4解绑后主托盘可继续验证', + '主托盘只有一箱可用库存,解绑会清空唯一业务托盘;必须由本轮生产矩阵提供至少两箱') + return false + } + const swapTarget = await createEmptyPallet(context, token, fixture, 'SWAP', main.destinationWarehouseCode) + const splitTarget = await createEmptyPallet(context, token, fixture, 'SPLIT', main.destinationWarehouseCode) + const unbindTarget = await createEmptyPallet(context, token, fixture, 'UNBIND', main.destinationWarehouseCode) + if (!swapTarget || !splitTarget || !unbindTarget) { + blocker(context, 'P4辅助实物托盘', '无法创建换托、拆托和解绑所需辅助托盘') + return false + } + + const swapBody = { idempotencyKey: key(context, 'PALLET-SWAP'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + target: { palletCode: swapTarget.palletCode, expectedVersion: Number(swapTarget.palletVersion) }, + reason: `${context.marker}-swap` } + const swapped = await replayOperation(context, token, 'P4整托换托', `${base}/swap`, swapBody, 'PALLET_SWAP') + await expectConflict(context, token, 'POST', 'P4换托同键异载荷A0409', `${base}/swap`, + { ...swapBody, reason: `${swapBody.reason}-DIFFERENT` }) + let occupiedSwap = operationPallet(swapped.data, swapTarget.palletCode) + || (await resolvePallet(context, token, swapTarget.palletCode, 'P4换托后解析目标托盘')).data + let emptyMain = operationPallet(swapped.data, main.palletCode) + || (await resolvePallet(context, token, main.palletCode, 'P4换托后解析原托盘')).data + await expectConflict(context, token, 'POST', 'P4换托旧托盘版本A0409', `${base}/swap`, { + ...swapBody, idempotencyKey: key(context, 'PALLET-SWAP-STALE'), + source: { ...swapBody.source }, target: { ...swapBody.target }, + }) + const swapBackBody = { idempotencyKey: key(context, 'PALLET-SWAP-BACK'), + source: { palletCode: occupiedSwap.palletCode, expectedVersion: Number(occupiedSwap.palletVersion) }, + target: { palletCode: emptyMain.palletCode, expectedVersion: Number(emptyMain.palletVersion) }, + reason: `${context.marker}-swap-back` } + await replayOperation(context, token, 'P4换托恢复主托盘', `${base}/swap`, swapBackBody, 'PALLET_SWAP') + + main = (await resolvePallet(context, token, main.palletCode, 'P4拆托前刷新主托盘')).data + const sourceLine = main.inventory.find((line) => Number(line.availablePackages) > 1) + || main.inventory.find((line) => Number(line.availablePackages) > 0) + const onePackage = 1 + const disassembleBody = { idempotencyKey: key(context, 'PALLET-DISASSEMBLE'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + targets: [{ target: { palletCode: splitTarget.palletCode, expectedVersion: Number(splitTarget.palletVersion) }, + lines: [{ positionId: id(sourceLine.positionId), packages: onePackage, + expectedPositionVersion: Number(sourceLine.positionVersion) }] }], + reason: `${context.marker}-disassemble` } + const disassembled = await replayOperation(context, token, 'P4拆托一箱到辅助托盘', + `${base}/disassemble`, disassembleBody, 'PALLET_DISASSEMBLE') + const splitOccupied = operationPallet(disassembled.data, splitTarget.palletCode) + || (await resolvePallet(context, token, splitTarget.palletCode, 'P4拆托后解析辅助托盘')).data + const mainAfterSplit = operationPallet(disassembled.data, main.palletCode) + || (await resolvePallet(context, token, main.palletCode, 'P4拆托后解析主托盘')).data + const splitLine = splitOccupied.inventory.find((line) => Number(line.availablePackages) > 0) + const assembleBody = { idempotencyKey: key(context, 'PALLET-ASSEMBLE'), + target: { palletCode: mainAfterSplit.palletCode, expectedVersion: Number(mainAfterSplit.palletVersion) }, + sources: [{ palletCode: splitOccupied.palletCode, expectedVersion: Number(splitOccupied.palletVersion) }], + lines: [{ positionId: id(splitLine.positionId), packages: Number(splitLine.availablePackages), + expectedPositionVersion: Number(splitLine.positionVersion) }], + reason: `${context.marker}-assemble` } + await replayOperation(context, token, 'P4合托恢复主托盘', `${base}/assemble`, assembleBody, 'PALLET_ASSEMBLE') + + main = (await resolvePallet(context, token, main.palletCode, 'P4解绑前刷新主托盘')).data + const unbindSource = main.inventory.find((line) => Number(line.availablePackages) > 1) + || main.inventory.find((line) => Number(line.availablePackages) > 0) + const unbindPreparedBody = { idempotencyKey: key(context, 'PALLET-UNBIND-PREPARE'), + source: { palletCode: main.palletCode, expectedVersion: Number(main.palletVersion) }, + targets: [{ target: { palletCode: unbindTarget.palletCode, expectedVersion: Number(unbindTarget.palletVersion) }, + lines: [{ positionId: id(unbindSource.positionId), packages: 1, + expectedPositionVersion: Number(unbindSource.positionVersion) }] }], + reason: `${context.marker}-unbind-prepare` } + const prepared = await replayOperation(context, token, 'P4拆出一箱作为解绑样本', + `${base}/disassemble`, unbindPreparedBody, 'PALLET_DISASSEMBLE') + const boundAuxiliary = operationPallet(prepared.data, unbindTarget.palletCode) + || (await resolvePallet(context, token, unbindTarget.palletCode, 'P4解析解绑样本托盘')).data + const unbindBody = { idempotencyKey: key(context, 'PALLET-UNBIND'), + source: { palletCode: boundAuxiliary.palletCode, expectedVersion: Number(boundAuxiliary.palletVersion) }, + reason: `${context.marker}-unbind` } + const unbound = await replayOperation(context, token, 'P4解绑辅助托盘库存', + `${base}/unbind`, unbindBody, 'PALLET_UNBIND') + context.assert(MODULE, 'P4主托盘保留可继续验证状态且辅助托盘解绑完成', unbound.response, + unbound.data?.status === 'COMPLETED' + && operationPallet(unbound.data, boundAuxiliary.palletCode)?.status === 'IDLE', + trace(unbound.response), OPTIONS) + return success(unbound.response) +} + +async function verifyRelabel(context, token, fixture) { + const customerId = id(fixture.order?.order?.customerId || context.createdResources?.phase4OverseasPda?.customerId) + const customerOrderNo = fixture.order?.order?.orderNo || fixture.order?.order?.sourceOrderNo + || `PDA-${context.marker}` + const inventory = fixture.inventory.find((item) => nonBlank(item?.skuCode)) + if (!customerId || !inventory?.skuCode || !fixture.overseasWarehouseId) { + blocker(context, 'P5本轮库存换标申请', '缺少本轮完成库存的客户、SKU或海外仓事实') + return false + } + const relabelBase = '/api/platform/v1/last-leg/relabel-applications' + const pdaBase = '/api/pda/v1/last-leg/relabel-tasks' + const newSku = `${inventory.skuCode}-NEW-${String(context.marker).slice(-8)}`.slice(0, 128) + const createBody = { + customerOrderNo: String(customerOrderNo).slice(0, 128), overseasWarehouseId: id(fixture.overseasWarehouseId), + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: 'PDA换标验收', contactPhone: '13800000006', remark: context.marker, + idempotencyKey: key(context, 'RELABEL-CREATE'), + items: [{ skuCode: inventory.skuCode, productNameZh: inventory.productName || '本轮库存商品', + totalPackageCount: 3, notifiedPackageCount: 3, shipmentNo: `SHIP-${context.marker}`.slice(0, 128), + newSkuCode: newSku, labelRequirement: context.marker }], + } + const createdResponse = await context.requestOverseas('POST', + query(relabelBase, { customerId }), createBody) + const created = success(createdResponse) ? createdResponse.body?.data : null + const relabelNo = id(created?.application?.relabelNo) + context.addStep(MODULE, 'P5基于本轮完成库存创建真实换标申请', createdResponse, + success(createdResponse) && nonBlank(relabelNo) + && created?.application?.status === 'PENDING_PROCESSING', + `relabelNo=${relabelNo || 'missing'}, sku=${inventory.skuCode}; ${trace(createdResponse)}`, OPTIONS) + if (!relabelNo) { + blocker(context, 'P5真实换标申请', '平台正式换标创建接口未返回申请单号', createdResponse) + return false + } + const candidates = await request(context, token, 'GET', 'P5读取当前仓换标候选', `${pdaBase}/candidates`) + context.assert(MODULE, 'P5候选包含本轮真实申请', candidates.response, + Array.isArray(candidates.data) && candidates.data.some((item) => id(item?.relabelNo) === relabelNo), + `relabelNo=${relabelNo}; ${trace(candidates.response)}`, OPTIONS) + const available = await request(context, token, 'GET', 'P5读取换标任务详情', + `${pdaBase}/${encodeURIComponent(relabelNo)}`) + const claimBody = { idempotencyKey: key(context, 'RELABEL-CLAIM') } + const claimed = await request(context, token, 'POST', 'P5认领换标任务', + `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody) + const claimReplay = await request(context, token, 'POST', 'P5认领换标同键重放', + `${pdaBase}/${encodeURIComponent(relabelNo)}/claim`, claimBody) + context.assert(MODULE, 'P5认领重放保持PROCESSING', claimReplay.response, + claimReplay.data?.taskStatus === 'PROCESSING' && claimed.data?.taskStatus === 'PROCESSING', + trace(claimReplay.response), OPTIONS) + let detail = claimed.data || available.data + const item = detail?.items?.[0] + if (!id(item?.itemId)) { + blocker(context, 'P5换标明细', '认领后的换标任务没有真实明细', claimed.response) + return false + } + const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units` + const scanBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-1`, newLabel: newSku, + expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-NORMAL') } + const scanned = await request(context, token, 'POST', 'P5正常换标一箱', unitPath, scanBody) + const scanReplay = await request(context, token, 'POST', 'P5正常换标同键重放', unitPath, scanBody) + context.assert(MODULE, 'P5正常换标重放不重复计数', scanReplay.response, + Number(scanReplay.data?.relabelledPackages) === 1, trace(scanReplay.response), OPTIONS) + await expectConflict(context, token, 'POST', 'P5正常换标同键异载荷A0409', unitPath, + { ...scanBody, oldLabel: `${context.marker}-OLD-DIFFERENT` }) + await expectConflict(context, token, 'POST', 'P5正常换标旧版本A0409', unitPath, + { ...scanBody, oldLabel: `${context.marker}-OLD-STALE`, idempotencyKey: key(context, 'RELABEL-STALE') }) + detail = scanned.data + const skipBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-2`, + reason: `${context.marker}-skip`, expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-SKIP') } + const skipped = await request(context, token, 'POST', 'P5跳过换标一箱', `${unitPath}/skip`, skipBody) + const skipReplay = await request(context, token, 'POST', 'P5跳过换标同键重放', `${unitPath}/skip`, skipBody) + context.assert(MODULE, 'P5跳过换标重放不重复计数', skipReplay.response, + Number(skipReplay.data?.skippedPackages) === 1, trace(skipReplay.response), OPTIONS) + detail = skipped.data + const exceptionBody = { itemId: id(item.itemId), oldLabel: `${context.marker}-OLD-3`, + exceptionCode: 'DAMAGED', reason: `${context.marker}-damaged`, expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-EXCEPTION') } + const exception = await request(context, token, 'POST', 'P5登记换标异常一箱', + `${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody) + const exceptionReplay = await request(context, token, 'POST', 'P5换标异常同键重放', + `${pdaBase}/${encodeURIComponent(relabelNo)}/exceptions`, exceptionBody) + context.assert(MODULE, 'P5换标异常重放不重复计数', exceptionReplay.response, + Number(exceptionReplay.data?.exceptionPackages) === 1, trace(exceptionReplay.response), OPTIONS) + detail = exception.data + + const photo = await context.requestOverseasMultipart('POST', + `${pdaBase}/${encodeURIComponent(relabelNo)}/photos/files`, { + name: `${context.marker}-relabel.png`, type: 'image/png', content: PNG, + }, token) + const photoId = id(photo?.body?.data?.fileId) + context.addStep(MODULE, 'P5上传换标完成照片', photo, + success(photo) && /^\d+$/.test(photoId) && photoId !== '0', + `fileId=${photoId || 'missing'}; ${trace(photo)}`, OPTIONS) + if (!photoId) return false + const completePath = `${pdaBase}/${encodeURIComponent(relabelNo)}/complete` + const completeBody = { photoFileIds: [photoId], expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` } + const completed = await request(context, token, 'POST', 'P5首次完成换标进入WAIT_REVIEW', completePath, completeBody) + const completeReplay = await request(context, token, 'POST', 'P5首次完成同键重放', completePath, completeBody) + context.assert(MODULE, 'P5首次完成重放保持WAIT_REVIEW', completeReplay.response, + completeReplay.data?.taskStatus === 'WAIT_REVIEW', trace(completeReplay.response), OPTIONS) + const reviewPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/review` + const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data?.version), + idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } + const reworked = await request(context, token, 'POST', 'P5复核退回REWORK', reviewPath, reworkBody) + const reworkReplay = await request(context, token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) + context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response, + reworkReplay.data?.taskStatus === 'PROCESSING', trace(reworkReplay.response), OPTIONS) + const secondCompleteBody = { photoFileIds: [photoId], expectedVersion: Number(reworked.data?.version), + idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` } + const secondCompleted = await request(context, token, 'POST', 'P5返工后再次完成WAIT_REVIEW', + completePath, secondCompleteBody) + const secondReplay = await request(context, token, 'POST', 'P5再次完成同键重放', completePath, secondCompleteBody) + context.assert(MODULE, 'P5再次完成重放保持WAIT_REVIEW', secondReplay.response, + secondReplay.data?.taskStatus === 'WAIT_REVIEW', trace(secondReplay.response), OPTIONS) + const approveBody = { decision: 'APPROVE', expectedVersion: Number(secondCompleted.data?.version), + idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` } + const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody) + const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody) + context.assert(MODULE, 'P5最终任务和申请均COMPLETED', approveReplay.response, + approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'COMPLETED' + && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) + return success(approved.response) +} + +export async function run(context) { + const handoff = context.context?.phase4OverseasPdaOperations + const token = handoff?.primary?.token + try { + if (handoff?.marker !== context.marker || !nonBlank(token) + || !Array.isArray(handoff.completedFixtures) || handoff.completedFixtures.length === 0) { + blocker(context, 'P2-P5本轮PDA收货交接', + 'phase4-overseas-pda未交接本轮primary picker会话与已完成库存事实') + return + } + const fixture = handoff.completedFixtures.find((item) => Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 1)) + || handoff.completedFixtures.find((item) => Array.isArray(item?.inventory) + && item.inventory.some((line) => Number(line?.availablePackages) > 0)) + if (!fixture) { + blocker(context, 'P2-P5可操作库存', '本轮已完成柜没有可移动的真实库存位置') + return + } + fixture.overseasWarehouseId = id(handoff.overseasWarehouseId) + await verifyStocktake(context, token, fixture) + await verifyInventoryMove(context, token, fixture) + await verifyPalletOperations(context, token, fixture) + await verifyRelabel(context, token, fixture) + } finally { + try { + if (typeof handoff?.cleanup === 'function') await handoff.cleanup() + } finally { + if (handoff?.primary) delete handoff.primary.token + if (handoff?.secondary) delete handoff.secondary.token + delete context.context.phase4OverseasPdaOperations + } + } +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b9edf6c8f..e7e7040c1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -50,7 +50,15 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) { if (mapped) { context.createdResources.phase4OverseasPda.orderId = id(order.id) context.createdResources.phase4OverseasPda.customerId = id(order.customerId) + const detailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}?customerId=${encodeURIComponent(id(order.customerId))}` + const detail = await context.requestOverseas('GET', detailPath) + context.assert(MODULE, '完成柜回查尾程订单货物明细供后续PDA作业', detail, + success(detail) && id(detail.body?.data?.order?.id) === id(order.id) + && Array.isArray(detail.body?.data?.cargo) && detail.body.data.cargo.length > 0, + `orderId=${id(order.id)}, cargo=${detail.body?.data?.cargo?.length ?? 'missing'}; ${trace(detail)}`, OPTIONS) + return success(detail) ? detail.body?.data : null } + return null } async function loginMerchantForNegativeProbe(context) { @@ -172,6 +180,9 @@ async function verifyWarehouseQueries(context, token, inboundCode, pallet) { && historyRows.some((item) => String(item?.operationType || '').startsWith('RECEIVING_')) && Number(history.data?.page) === 1 && Number(history.data?.size) === 50, `records=${historyRows.length}; ${trace(history.response)}`, OPTIONS) + return success(inventory.response) && success(location.response) && success(resolvedPallet.response) + ? { inboundCode, pallet: resolvedPallet.data, location: location.data, inventory: inventory.data?.items || [] } + : null } async function uploadPhotos(context, token) { @@ -432,10 +443,15 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { - await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) - const firstInbound = current.data.inbounds.find((item) => pallets.has(item.inboundCode)) - if (firstInbound) { - await verifyWarehouseQueries(context, token, firstInbound.inboundCode, pallets.get(firstInbound.inboundCode)) + const order = await captureCompletedOrder(context, containerNo, preferredProducer?.tenantId) + for (const inbound of current.data.inbounds.filter((item) => pallets.has(item.inboundCode))) { + const warehouseFacts = await verifyWarehouseQueries(context, token, inbound.inboundCode, + pallets.get(inbound.inboundCode)) + if (warehouseFacts && context.context?.phase4OverseasPdaOperations) { + context.context.phase4OverseasPdaOperations.completedFixtures.push({ + containerNo, order, ...warehouseFacts, + }) + } } } } @@ -462,6 +478,19 @@ export async function run(context) { for (const picker of [primary, secondary]) if (picker?.membershipId) { context.createdResources.phase4OverseasPda.pickerMembershipIds.push(picker.membershipId) } + let handoffReady = false + const cleanup = async () => { + await disablePicker(context, secondary, 2) + await disablePicker(context, primary, 1) + } + context.context.phase4OverseasPdaOperations = { + marker: context.marker, + primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null, + secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null, + overseasWarehouseId: sharedWarehouse.id, + completedFixtures: [], + cleanup, + } try { if (!primary?.token || !secondary?.token) { blockerGap(context, '海外PDA双操作员账号', '未能创建并登录两个WAREHOUSE_PICKER平台账号,无法验证全局一柜锁') @@ -486,10 +515,13 @@ export async function run(context) { context.createdResources.phase4OverseasPda.completedContainers.push(producer?.containerNo || null) } } + handoffReady = context.context.phase4OverseasPdaOperations.completedFixtures.length > 0 } finally { delete context.pdaContainerSuffix - await disablePicker(context, secondary, 2) - await disablePicker(context, primary, 1) + if (!handoffReady) { + try { await cleanup() } + finally { delete context.context.phase4OverseasPdaOperations } + } } } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 0a1cf2a60..05baae375 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -10,6 +10,7 @@ test('phase-four HTTP acceptance modules are registered in the Admin acceptance for (const module of [ 'phase4-overseas-warehouse', 'phase4-overseas-pda', + 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', @@ -27,7 +28,7 @@ test('overseas inbound commands, client services and dispatch follow completed P 'phase4-overseas-dispatch']) { const modules = parseArguments(['--modules', consumer]).modules for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', - 'phase3-loading-finalize', 'phase4-overseas-pda']) { + 'phase3-loading-finalize', 'phase4-overseas-pda', 'phase4-overseas-pda-operations']) { assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf(consumer)) } @@ -82,4 +83,5 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) assert.match(source, /readyForPhase4Pda === true/) + assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) }) From 6ba4537d3db1493122cac631b7cb6c96df9889d2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:39:12 +0800 Subject: [PATCH 153/231] fix(deploy): bind development ports to loopback --- deploy/docker-compose.overseas.yml | 8 +++---- deploy/docker-compose.yml | 22 +++++++++---------- deploy/scripts/deploy-built-service.sh | 12 ++++++++++ deploy/scripts/deploy-development-service.sh | 2 ++ ...est-environment-pipeline-contract.test.mjs | 10 +++++++++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 29f78af91..5eb84191d 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -37,7 +37,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas - ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"] environment: <<: *overseas-environment @@ -52,7 +52,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-lastleg - ports: ["${LASTLEG_PORT:-9303}:9303"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"] environment: <<: *overseas-environment @@ -70,7 +70,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas - ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"] environment: <<: *overseas-environment @@ -83,7 +83,7 @@ services: <<: *overseas-common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas - ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"] environment: <<: *overseas-environment diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e917070cf..4f6700005 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,7 +39,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-gateway - ports: ["${GATEWAY_PORT:-9000}:9000"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"] environment: <<: *common-environment @@ -52,7 +52,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-auth - ports: ["${AUTH_PORT:-9100}:9100"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"] environment: <<: *common-environment @@ -71,7 +71,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-iam - ports: ["${IAM_PORT:-9201}:9201"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"] environment: <<: *common-environment @@ -84,7 +84,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-business - ports: ["${FIRSTLEG_PORT:-9202}:9202"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"] environment: <<: *common-environment @@ -110,7 +110,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-finance - ports: ["${FINANCE_PORT:-9203}:9203"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"] environment: <<: *common-environment @@ -123,7 +123,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-order - ports: ["${ORDER_PORT:-9204}:9204"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"] environment: <<: *common-environment @@ -136,7 +136,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-warehouse - ports: ["${WAREHOUSE_PORT:-9205}:9205"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"] environment: <<: *common-environment @@ -149,7 +149,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-file - ports: ["${FILE_PORT:-9206}:9206"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" - "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs" @@ -173,7 +173,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-print - ports: ["${PRINT_PORT:-9207}:9207"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"] environment: <<: *common-environment @@ -184,7 +184,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-integration - ports: ["${INTEGRATION_PORT:-9210}:9210"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"] environment: <<: *common-environment @@ -234,7 +234,7 @@ services: <<: *common image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge - ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"] + ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"] volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"] environment: <<: *common-environment diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 790431300..860474778 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -36,6 +36,13 @@ case "$DEPLOY_ENV" in ;; esac +if [ "$DEPLOY_ENV" = "development" ]; then + PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi +export PUBLISHED_BIND_ADDRESS + case "$SERVICE" in *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2 @@ -390,6 +397,11 @@ cd "$remote_dir" export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" +if [ "$deploy_env" = "development" ]; then + export PUBLISHED_BIND_ADDRESS=127.0.0.1 +else + export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" +fi compose() { docker compose \ diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh index b1034e2b9..23343f7a4 100644 --- a/deploy/scripts/deploy-development-service.sh +++ b/deploy/scripts/deploy-development-service.sh @@ -64,6 +64,7 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then cd "$REMOTE_DIR" export DEPLOY_ENV=development export IMAGE_REPOSITORY IMAGE_TAG + export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ --project-name "$PROJECT_NAME" \ @@ -143,6 +144,7 @@ cd "$remote_dir" export DEPLOY_ENV=development export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" +export PUBLISHED_BIND_ADDRESS=127.0.0.1 docker compose \ --project-name "$project_name" \ diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index cb7a9566c..4a69ce5fe 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -12,6 +12,10 @@ const ciMavenSettings = (await readFile(new URL('../deploy/maven/settings-ci.xml .replaceAll('\r\n', '\n') const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') +const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') +const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) @@ -77,6 +81,12 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) +test('development publishes service ports on loopback without changing test defaults', () => { + assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) + assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) + assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then[\s\S]*?PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) +}) + test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), From d066381ae2220bd14e6698b6394da965850c4857 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:41:25 +0800 Subject: [PATCH 154/231] fix: expose failed deployment container logs --- deploy/scripts/deploy-built-service.sh | 14 ++++++++++++++ scripts/deploy-built-service-rollback.test.mjs | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 860474778..f47f8f2c9 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -273,6 +273,13 @@ deploy_local() { fi echo "新镜像健康检查失败: $IMAGE" >&2 + failed_container_id="$(local_container_id || true)" + if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true + else + echo "新镜像容器不存在,无法读取启动日志" >&2 + fi expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" case "$previous_image" in "$expected_prefix"*) @@ -476,6 +483,13 @@ if [ "$new_started" = "true" ] && wait_for_health; then fi echo "新镜像健康检查失败: $new_image" >&2 +failed_container_id="$(remote_container_id || true)" +if [ -n "$failed_container_id" ]; then + echo "新镜像容器最近日志(最多 200 行):" >&2 + docker logs --tail 200 "$failed_container_id" 2>&1 || true +else + echo "新镜像容器不存在,无法读取启动日志" >&2 +fi expected_prefix="$image_repository/$service:" case "$previous_image" in "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e8103388e..84906b7b8 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -54,6 +54,9 @@ case "$command" in *) exit 2 ;; esac ;; + logs) + echo 'mock startup failure' + ;; *) echo "unexpected docker command: $command $*" >&2 exit 2 @@ -129,6 +132,9 @@ test('unhealthy deployment restores the previous healthy image and still fails t assert.equal(result.status, 1, output) assert.match(output, /新镜像健康检查失败: registry\.local\/group\/firstleg\/audit-service:newbad/) + assert.match(output, /新镜像容器最近日志(最多 200 行):/) + assert.match(output, /mock startup failure/) + assert.match(result.dockerLog, /logs --tail 200 mock-container/) assert.match(output, /开始自动回滚到: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.match(result.dockerLog, /pull registry\.local\/group\/firstleg\/audit-service:oldgood/) From d64706e97bcfd573358e6753d038d93c8c3fe28b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:44:38 +0800 Subject: [PATCH 155/231] fix: keep development gateways reachable --- deploy/scripts/deploy-built-service.sh | 10 ++++++++-- scripts/test-environment-pipeline-contract.test.mjs | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f47f8f2c9..dd0aef38d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -37,7 +37,10 @@ case "$DEPLOY_ENV" in esac if [ "$DEPLOY_ENV" = "development" ]; then - PUBLISHED_BIND_ADDRESS=127.0.0.1 + case "$SERVICE" in + gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac else PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi @@ -405,7 +408,10 @@ export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" if [ "$deploy_env" = "development" ]; then - export PUBLISHED_BIND_ADDRESS=127.0.0.1 + case "$service" in + gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; + *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; + esac else export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 4a69ce5fe..cb9d380ce 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -81,10 +81,13 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) -test('development publishes service ports on loopback without changing test defaults', () => { +test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => { assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) - assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then[\s\S]*?PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) + assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) }) test('parallel image publications isolate registry credentials per Jenkins job', () => { From 4fbf8bfb53c675177b693320f6dca1667d337e1b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:52:25 +0800 Subject: [PATCH 156/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 15 +- AGENTS.md | 5 +- README.md | 14 +- common-event-contract/AGENTS.md | 3 +- docs/同步调用依赖与失败传播治理基线.md | 105 +++++++ docs/头程项目目录结构与服务说明.md | 17 +- firstleg/AGENTS.md | 1 + ...rstleg-internal-modularization-baseline.md | 189 ++++++++++++ .../scripts/analyze-firstleg-dependencies.mjs | 284 ++++++++++++++++++ .../analyze-firstleg-dependencies.test.mjs | 39 +++ .../firstleg-slice-dependencies.json | 48 +++ lastleg-service/AGENTS.md | 1 + pom.xml | 23 ++ scripts/agents-index-contract.ps1 | 39 ++- scripts/java-topology-contract.test.mjs | 146 +++++++++ .../synchronous-dependency-contract.test.mjs | 188 ++++++++++++ 16 files changed, 1101 insertions(+), 16 deletions(-) create mode 100644 docs/同步调用依赖与失败传播治理基线.md create mode 100644 firstleg/docs/firstleg-internal-modularization-baseline.md create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.mjs create mode 100644 firstleg/scripts/analyze-firstleg-dependencies.test.mjs create mode 100644 firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json create mode 100644 scripts/java-topology-contract.test.mjs create mode 100644 scripts/synchronous-dependency-contract.test.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4dd6604f7..4c67d8bc7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -13,6 +13,13 @@ cache: paths: - .m2/repository/ +Java拓扑合同: + stage: verify + image: node:22-alpine + cache: [] + script: + - node --test scripts/java-topology-contract.test.mjs + 校验与测试: stage: verify script: @@ -25,10 +32,13 @@ cache: - "**/target/surefire-reports/TEST-*.xml" paths: - "**/target/surefire-reports/" + - "**/target/site/jacoco/" + - "**/target/jacoco.exec" 打包服务: stage: package needs: + - Java拓扑合同 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package @@ -36,14 +46,17 @@ cache: expire_in: 7 days paths: - gateway/target/*.jar + - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar - firstleg/target/*.jar + - finance/target/*.jar - warehouse/target/*.jar + - warehouse-overseas-service/target/*.jar + - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - event-bridge-service/target/*.jar - diff --git a/AGENTS.md b/AGENTS.md index 2d5d600f2..2b612f218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,10 +37,11 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 海外入仓分页、FBA 预约、PDA 卸柜照片 | `lastleg-service/` | `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`、`inboundplan/pda/` | +| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 到仓、卸柜、实收、上架、合托 | `lastleg-service/`、`warehouse-overseas-service/` | 业务流程 `lastleg-service/inboundplan/pda/`;库位、托盘和资源锁归 `warehouse-overseas-service/`;设备端 `../apps/pda-overseas-uniapp/`,公网入口 `gateway-overseas/` | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | diff --git a/README.md b/README.md index 884c9de44..d8202d879 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 柏悦头程平台 -本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。 +本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -12,20 +12,24 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。 +- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 -- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 +- `platform-bom`:统一管理仓库内公共依赖版本。 ## 本地构建 ```powershell mvn -DskipTests validate +node --test scripts/java-topology-contract.test.mjs mvn test ``` +GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 + 2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。 +国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -41,4 +45,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index c35c4997c..bf9e87321 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -17,9 +17,10 @@ | 头程装柜完成 V2(当前生产) | `FirstLegLoadingPlanCompletedV2.java`;订单级冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;同时冻结来源/根订单与入仓标识、客户、业务类型、目的仓/备注/托盘及一键代发托盘 SKU 快照;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java` | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | +| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | -| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 修改与验证 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md new file mode 100644 index 000000000..5755b0d21 --- /dev/null +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -0,0 +1,105 @@ +# 同步调用依赖与失败传播治理基线 + +## 目的与口径 + +本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 + +“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: + +- `REQUEST`:用户或设备请求线程内,失败直接影响当前业务结果; +- `BACKGROUND`:Outbox、Inbox、定时器或导出 Worker 中的同步传输,业务事务已与传输解耦; +- `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; +- `EXTERNAL`:Integration 对第三方供应商的调用。 + +离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 + +## 当前服务边 + +### OpenFeign + +| 调用方 | 被调用方 | 客户端数 | 主要上下文 | +|---|---|---:|---| +| Auth | IAM | 2 | 登录授权、注册,`REQUEST` | +| IAM | Auth | 1 | 账号开通/管理,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` | +| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | +| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | +| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | +| Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | +| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | +| Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | +| Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | +| Finance | Firstleg | 2 | 费用来源/成本快照,事件消费与平台操作混合 | +| Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | +| File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | +| File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | +| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 | +| Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | +| Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | + +合计 44 个 Feign 客户端。公共默认连接/读取超时为 3 秒/5 秒;File 的五个导出 Provider 客户端读取超时单独为 15 秒。 + +### 原始 HTTP + +| 调用方 | 被调用方 | 实现 | 上下文与当前策略 | +|---|---|---|---| +| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 | +| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 | +| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | +| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | + +## 依赖环基线 + +按服务边计算,当前唯一非平凡强连通分量包含 8 个服务: + +`Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` + +其中优先关注的直接双向边为: + +1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖; +2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递; +3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询; +4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照; +5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加; +6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。 + +强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。 + +## 退出顺序 + +1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 +2. **统一失败传播与时间预算。** 为 Feign 和原始 HTTP 明确连接/读取/总超时;默认禁用自动重试,只有具备幂等键且由业务明确拥有退避状态的后台操作可以重试;禁止空数据、默认成功或仅日志告警替代远端失败。 +3. **先退出伪在线环。** File 导出 Provider、Firstleg/Lastleg Event Bridge、Firstleg→Finance 费用投递继续作为后台链路,但必须从在线深度图剥离,并禁止 Worker 消费时无界同步回调。 +4. **退出 `Order ↔ Firstleg` 在线双向命令。** 保留一个方向的权威查询,将反向状态推进改为带版本、幂等和审计的 Outbox/Inbox;不复制 Order 交易状态或 Firstleg 履约状态。 +5. **退出 `Auth ↔ IAM` 在线双向写。** Auth 保持账号与 Token 唯一所有者,IAM 保持客户/RBAC 所有者;注册和账号开通使用可恢复编排,登录授权快照失败关闭,不引入第二套账号事实。 +6. **收敛 `IAM ↔ Firstleg`。** 客户维度规则和客户快照确定单一事实方向;允许短期只读查询,禁止两个服务互相修正对方数据。 +7. **在财务总控确认后退出 `Firstleg ↔ Finance` 回调。** 优先让版本化事件携带 Finance 校验所需的不可变快照或使用单向快照读取,保持 Finance 状态机和 Firstleg 费用事实边界不变。 +8. **收敛 File 相关环。** File 只拥有文件对象与导出任务;业务服务拥有导出数据和业务归属。凭证/照片校验失败关闭,导出 Worker 使用受限批次、超时和显式失败状态。 + +## 错误、重试与熔断基线 + +- 远端 HTTP 非 2xx、响应体 `code != "200"`、空响应和 DTO 不完整是四类不同失败,均不得变成成功或空列表。 +- 在线查询/命令默认不自动重试。若启用重试,必须同时证明方法幂等、请求具有稳定幂等键、最大尝试次数和总时间预算可控。 +- Outbox/Inbox 重试由持久化状态拥有,Feign/JDK 客户端不得再叠加隐式重试造成次数乘法。 +- 熔断打开时返回统一“依赖不可用”错误并保留 request/trace 关联信息,不配置业务成功型 fallback。 +- 404 只有在契约明确“资源不存在”时可映射为领域未找到;连接失败、超时、5xx、空响应必须映射为依赖不可用。 +- Common Audit 当前“只记录告警后返回”列为已知例外,不能据此推广静默降级;其可靠性与业务阻断口径需和审计/总控共同冻结。 + +## 直接受影响验证 + +| 变更范围 | 最低验证 | +|---|---| +| 图、客户端或超时配置 | `node --test scripts/synchronous-dependency-contract.test.mjs` | +| 单个 Feign/HTTP 签名或错误映射 | `mvn -pl <调用方>,<被调用方> -am test-compile`,并运行双方直接相关测试 | +| Auth/IAM | `mvn -pl auth,iam -am test` | +| Order/Firstleg | `mvn -pl order,firstleg -am test` | +| IAM/Firstleg | `mvn -pl iam,firstleg -am test` | +| Firstleg/Finance | `mvn -pl firstleg,finance -am test`;业务口径先由财务总控确认 | +| Firstleg/File 或 File/Finance | `mvn -pl firstleg,file,finance -am test`,按实际消费者缩小模块集 | +| Lastleg 原始 HTTP | `mvn -pl lastleg-service,warehouse-overseas-service,file -am test`;不改变四期状态机 | +| 全部治理阶段收口 | `mvn -DskipTests validate` 后执行 `mvn test` | + +以上均为本地离线验证;未经明确授权不启动 Java/Nacos/数据库,不执行 SQL、部署或测试环境 HTTP 验收。 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 9c15e66f1..0fad3c9d3 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程业务后端仓库,采用 Maven 多模块单仓结构。仓库内包含 7 个公共模块和 11 个可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -本仓库只保存头程代码。尾程业务位于独立 Git 仓库,不允许把尾程源码、数据库或配置重新放回本仓库。 +国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 ## 2. 根目录结构 @@ -39,11 +39,15 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 +├─ gateway-overseas/ # 海外区域 API 网关 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 ├─ firstleg/ # 履约任务、二期入库、测量与计费 ├─ warehouse/ # 仓库主数据、托盘与库位资源 +├─ finance/ # 财务账期、账单、收付款与核销 +├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 +├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -170,7 +174,7 @@ config/nacos/ └─ cn/ # 国内服务器配置模板 ``` -每套目录包含公共配置 `baiyue-common.yml` 和 11 个服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -179,7 +183,8 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 11 个业务服务编排 +├─ docker-compose.yml # 国内区域服务编排 +├─ docker-compose.overseas.yml # 海外区域服务编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 ├─ sql/ # 测试/存量环境统一迁移入口与说明 └─ service-map.yml # 模块、JAR、端口映射 @@ -276,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会自动重建全部 11 个服务。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 头程与尾程保持两个 Git 仓库、两套数据库和两套运行环境。 +- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 72b9200eb..a9afeb4a0 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -32,6 +32,7 @@ | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | +| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md new file mode 100644 index 000000000..3a37feacd --- /dev/null +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -0,0 +1,189 @@ +# Firstleg 内部模块化基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为: + +- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 +- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 + +可重复命令: + +```powershell +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set all --format json +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format dot +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出物理包图、逻辑切片图、SCC、跨切片类依赖、Entity/DTO 泄漏和 Mapper 泄漏。Spring 字符串扫描路径、反射、MyBatis SQL 中的表引用不属于 Java 类型图,因此在本报告单独标注。 + +## 2. 当前 SCC + +### 2.1 主源码物理包 SCC + +当前只有一个多节点物理包 SCC: + +```text +com.baiyue.firstleg.mapper + <-> com.baiyue.firstleg.repository +``` + +直接证据是全局 Repository 大量依赖全局 Mapper,同时 `InboundAllocationQueryMapper` 反向引用 `repository.PriceBookQueryRows`。这说明全局数据访问层尚不能按传统横向层直接单向化。 + +合并测试源码后,测试对生产类型的依赖把 SCC 扩大为下列十包;这不是生产运行时环,但表示测试也直接绑定了横向实现包: + +```text +controller -> loading.repository -> loading.service -> mapper +-> oceanbooking.controller -> oceanbooking.repository -> oceanbooking.service +-> pricing -> repository -> service -> controller +``` + +### 2.2 逻辑切片 SCC + +逻辑切片图存在一个八节点 SCC: + +```text +booking-customs-trucking +finance-integration +inbound +measurement-pricing +outbound +shared-technical +shipping-nodes +split-loading +``` + +`customer-statistics` 不在 SCC 内。当前 36 条切片间边均冻结在 `src/test/resources/architecture/firstleg-slice-dependencies.json`;合同允许删除现有边,不允许增加新边。 + +主要双向依赖包括: + +| 双向关系 | 直接证据 | +|---|---| +| 入库 ↔ 测量计价 | 入库编排直接使用测量/费用 Entity、Mapper;计价与客户费用查询直接使用入库单、状态和 Outbox | +| 入库 ↔ 分柜装柜 | 统一入库读模型读取装柜/PDA Mapper 与 Entity;装柜结算和事件发布读取入库状态、托盘与 Outbox | +| 分柜装柜 ↔ 出仓 | 货柜聚合读出仓历史;海外卸柜回写反向更新货柜/装柜事实 | +| 订舱报关拖车 ↔ 出仓 | 出仓门禁聚合报关/船期贡献者;报关和船期贡献者反向依赖出仓 DTO | +| 订舱报关拖车 ↔ 分柜装柜 | 订舱货柜分配使用装柜实体;报关直接读取装柜计划与明细 Mapper | +| 订舱报关拖车 ↔ 测量计价 | 价格簿校验影响订舱;供应商/渠道校验又由全局计价服务消费 | + +## 3. 八个业务切片现状映射 + +类数量和跨切片依赖数量以主源码图为准;“出/入”均为类级直接依赖条数。 + +| 业务切片 | 当前物理位置与识别范围 | 类数 | 出/入 | 主要边界问题 | +|---|---|---:|---:|---| +| 入库 | 全局 `controller/service/repository/mapper/entity/dto` 中 `Inbound*`、`Fulfillment*`、`Assignment*`、`UnifiedInbound*` | 67 | 26/60 | 被计价和装柜大量反向读取;`InboundWorkflowRepository` 直接持有测量/费用 Mapper | +| 测量计价 | 全局层中的 `Measurement*`、`Mojia*`、`Pricing*`、`PriceBook*`、`Fba*`、`Dimension*`、`FeeItem*`、`InboundFee*`,以及 `pricing/` | 113 | 72/25 | 最大切片;对入库有 35 条直接依赖,其中 20 条为跨切片数据模型泄漏、6 条为 Mapper 泄漏 | +| 分柜装柜 | `split/`、`loading/` 加全局 `Loading*`、`SmartLoading*`、货柜与 PDA 类 | 82 | 50/32 | 同时依赖入库、订舱、出仓和共享层;全局 Repository 直接读取 `loading/split` Mapper/Entity | +| 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity | +| 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity | +| 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity | +| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | +| 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 | + +共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。 + +## 4. 泄漏与逆向依赖基线 + +主源码共有 88 条跨切片 Entity/DTO 泄漏、31 条跨切片 Mapper 泄漏。高频项如下: + +| 来源 → 目标 | Entity/DTO | Mapper | 代表性证据 | +|---|---:|---:|---| +| 测量计价 → 入库 | 20 | 6 | `FbaPricingRepository -> InboundOutboxEntity/Mapper`;客户费用查询直接使用 `InboundOrderDetail` | +| 分柜装柜 → 入库 | 7 | 6 | `LoadingDeliveryService -> InboundStatusHistoryEntity/Mapper`;装柜 Outbox 复用入库 Outbox | +| 入库 → 测量计价 | 4 | 4 | `InboundWorkflowRepository -> InboundFee/Measurement/Review Mapper` | +| 入库 → 分柜装柜 | 4 | 3 | `UnifiedInboundReadRepository` 直接读取装柜证据、PDA 任务、预装计划 Mapper/Entity | +| 财务集成 → 入库 | 4 | 2 | 财务来源 Outbox 直接复用 `InboundOutboxEntity/Mapper` | +| 订舱报关拖车 → 分柜装柜 | 3 | 3 | `CustomsRepository -> LoadingPlan/LoadingPlanItem Mapper` | +| 分柜装柜 → 订舱报关拖车 | 3 | 0 | `ContainerService -> OceanBookingContainer*Entity` | +| 船节点 → 入库 | 1 | 1 | `CarrierScheduleNodeOutboxService -> InboundOutboxEntity/Mapper` | + +逆向依赖的判定不是简单的 `controller -> service -> repository` 层次关系,而是“业务核心直接依赖另一切片的持久化实现或 Entity”。首轮治理应优先用查询/命令 Port 替代这些直接引用,不共享领域 Entity。 + +## 5. 首切片选择:客户统计 + +选择 `customer-statistics`,不是因为预设,而是因为以下实际证据: + +1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。 +2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。 +3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。 +4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。 +5. 它仍包含一个明确的内部逆向依赖:应用 Service 直接依赖 MyBatis Mapper,适合用最小 Out Port 证明目标依赖方向。 + +限制:`CustomerTransitStatisticsMapper` 的 SQL 只读 `fl_ocean_booking`、`fl_container_booking`、`fl_loading_plan`、`fl_loading_plan_item`。首切片只隔离 Java 依赖,不宣称拥有这些表,也不改变 SQL、索引或统计口径。 + +## 6. 首切片目标目录与允许依赖 + +```text +customerstatistics/ + adapter/in/web/ + PlatformCustomerTransitStatisticsController.java + application/ + CustomerTransitStatisticsQueryService.java + model/CustomerTransitStatisticsModels.java + port/out/CustomerTransitStatisticsReader.java + adapter/out/persistence/ + CustomerTransitStatisticsMapper.java +``` + +允许方向: + +```text +adapter.in.web -> application +adapter.out.persistence -> application.port.out +application -> application.model + common-core +``` + +额外允许项仅限 Web 适配器使用 `common-security`、Spring Web 和现有 `FirstlegScopeGuard`。禁止 `application` 依赖 MyBatis、Spring MVC、全局 `entity/mapper/repository/service` 或其他业务切片。 + +## 7. 精确迁移清单、调用方与测试 + +首切片实施时只迁移/新增以下文件: + +| 当前文件 | 目标/动作 | +|---|---| +| `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java` | 移到 `adapter/in/web/`,URI、权限和响应保持不变 | +| `customerstatistics/dto/CustomerTransitStatisticsDtos.java` | 移到 `application/model/`,保持 JSON 字段与数值语义不变 | +| `customerstatistics/service/CustomerTransitStatisticsService.java` | 移到 `application/` 并改名为 Query Service,只依赖 Out Port | +| `customerstatistics/mapper/CustomerTransitStatisticsMapper.java` | 移到 `adapter/out/persistence/`,实现/扩展只读 Out Port;SQL 原样保留 | +| 新增 `customerstatistics/application/port/out/CustomerTransitStatisticsReader.java` | 定义 `summarize(customerId)` 与只读投影,不暴露 MyBatis 类型 | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsServiceTest.java` | Mock Out Port,不再 Mock Mapper | +| `src/test/java/.../customerstatistics/CustomerTransitStatisticsContractTest.java` | 更新包引用,继续锁定 URI、权限、SQL、OpenAPI 与索引语义 | +| `BaiyueFirstlegApplication.java`、`BaiyueFirstlegApplicationContractTest.java` | 只有 Mapper 扫描路径因移动确需调整时修改;不改变其他扫描包 | + +外部调用方只读核对,不在首切片修改: + +- `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts` +- `apps/admin-web/src/modules/container-loading/api/platformContainerLoading.test.ts` + +## 8. 回滚边界 + +- 不修改表、SQL 内容、接口路径、权限码、响应字段、统计定义、Nacos、部署或跨服务契约。 +- 回滚只涉及上述客户统计 Java 包移动、Out Port 和对应测试;可整体恢复到现有四类结构。 +- 若 Mapper 继承 Out Port 导致 MyBatis 代理行为或扫描合同失败,停止实施并恢复包内变更,不以增加全局扫描、共享 POM 依赖或兼容 Bean 绕过。 +- 首切片完成前不开始第二切片。 + +## 9. 当前只读合同与后续协作 + +本轮已增加模块内、无第三方依赖的只读合同: + +- `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。 +- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。 + +与其他架构专项的依赖: + +- 专项一(拓扑与质量门禁):决定是否引入 ArchUnit、如何把本合同接入 Maven/CI、以及共享 POM 和全局质量门禁。当前未修改 POM;建议由专项一把本脚本作为 Firstleg 局部门禁接入,再以 ArchUnit 替换/增强源码级解析。 +- 专项二(同步依赖与失败传播):在为其他切片定义应用 Port 前提供 Feign/HTTP 调用图和失败语义。客户统计当前没有跨服务 Client,因此首切片无需等待专项二;后续入库、财务集成、订舱和装柜切片必须协调。 + +## 10. 最小验证命令 + +```powershell +node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs +node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test +``` + +当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。 + diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs new file mode 100644 index 000000000..e2de4acf8 --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.firstleg.'; + +const sliceRules = [ + ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], + ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], + ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], + ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], + ['inbound', /(?:Inbound|Fulfillment|Assignment|UnifiedInbound|CartonLabel)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-firstleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (packageName.includes('.client')) return 'cross-service-adapter'; + if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const packageSccs = stronglyConnectedComponents(packages, packageEdges); + const sliceSccs = stronglyConnectedComponents(slices, sliceEdges); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => ['data-model'].includes(sourceByFqcn.get(edge.target).kind)); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'firstleg', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, imports, ...entry }) => entry), + packageEdges, + sliceEdges, + packageSccs, + sliceSccs, + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const lines = [ + '# Firstleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...[...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index) + .map(edge => `- \`${edge.source}\` -> \`${edge.target}\``), + '', + ]; + return lines.join('\n'); +} + +function renderDot(report) { + const lines = ['digraph firstleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const additions = actual.filter(edge => !allowed.has(edge)); + if (additions.length > 0) { + process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); + diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs new file mode 100644 index 000000000..9cf71321f --- /dev/null +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-firstleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'firstleg-slice-dependencies.json'); + +function analyze() { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('customer statistics remains an independently packaged low-coupling slice', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'customer-statistics'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController', + 'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos', + 'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper', + 'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService', + ]); + const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics'); + const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics'); + assert.deepEqual(outgoing, [{ source: 'customer-statistics', target: 'shared-technical', count: 1 }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false); +}); + diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json new file mode 100644 index 000000000..4607d9891 --- /dev/null +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "booking-customs-trucking->inbound", + "booking-customs-trucking->measurement-pricing", + "booking-customs-trucking->outbound", + "booking-customs-trucking->shared-technical", + "booking-customs-trucking->split-loading", + "customer-statistics->shared-technical", + "finance-integration->inbound", + "finance-integration->measurement-pricing", + "finance-integration->shared-technical", + "finance-integration->split-loading", + "inbound->measurement-pricing", + "inbound->outbound", + "inbound->shared-technical", + "inbound->split-loading", + "measurement-pricing->booking-customs-trucking", + "measurement-pricing->finance-integration", + "measurement-pricing->inbound", + "measurement-pricing->shared-technical", + "outbound->booking-customs-trucking", + "outbound->shared-technical", + "outbound->shipping-nodes", + "outbound->split-loading", + "shared-technical->booking-customs-trucking", + "shared-technical->finance-integration", + "shared-technical->inbound", + "shared-technical->measurement-pricing", + "shared-technical->split-loading", + "shipping-nodes->booking-customs-trucking", + "shipping-nodes->inbound", + "shipping-nodes->shared-technical", + "shipping-nodes->split-loading", + "split-loading->booking-customs-trucking", + "split-loading->inbound", + "split-loading->measurement-pricing", + "split-loading->outbound", + "split-loading->shared-technical" + ], + "sliceSpecificRules": { + "customer-statistics": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + } + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 7adcf9f70..f70c92a95 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -27,6 +27,7 @@ | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | | 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 | +| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java`、`shipping/entity/ContainerShippingGate.java`、`shipping/mapper/ContainerShippingGateMapper.java`;只单调接受实际标准节点,`PICKUP_READY` 及以后才允许对应柜进入 PDA 收货候选 | | 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`、`dispatch/mapper/DispatchCandidateMapper.java`、`dispatch/service/DispatchService.java`;草稿柜 `entity/DispatchDraftContainer.java`、`mapper/DispatchDraftContainerMapper.java`,托盘锁 `DispatchPalletLock.java`;入口 `/api/platform/v1/last-leg/dispatches`,范围开关与禁用写操作见“四期开发范围路由” | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` | diff --git a/pom.xml b/pom.xml index 35b79ff33..cbb4a2780 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,7 @@ 2.5.0 3.13.0 3.4.1 + 0.8.12 2.3.3 @@ -137,6 +138,28 @@ [3.8.8,) + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + 准备覆盖率采集 + prepare-agent + + + 生成覆盖率报告 + verify + report + + + HTML + XML + + + + + diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 4052af722..ad8e06621 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -115,6 +115,19 @@ foreach ($pdaApplication in $pdaApplications) { } } +# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。 +$overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' +$overseasWorkbench = Read-RequiredText $overseasWorkbenchPath +Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' +foreach ($disabledOperation in @( + '库存盘点', '商品移库', '托盘管理', + '换标收货', '换标执行', '换标复核', + '库存查询', '库位查询', '托盘查询' +)) { + $disabledPattern = "(?s)name:\s*'$([regex]::Escape($disabledOperation))'.{0,300}?available:\s*false" + Assert-Matches $overseasWorkbench $disabledPattern "Overseas PDA 禁用入口 $disabledOperation" +} + # 文档声明为正式开放的 Client 海外仓页面必须仍是 active 路由。 $clientOverseasRoutesPath = Join-Path $workspaceRoot 'apps/client-web/src/modules/overseas-warehouse/routes.ts' $clientOverseasRoutes = Read-RequiredText $clientOverseasRoutesPath @@ -134,6 +147,7 @@ $overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath foreach ($gatewayPath in @( '/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', + '/api/pda/v1/last-leg/**', '/api/merchant/v1/warehouses/**', '/api/platform/v1/warehouse-admin/**' )) { @@ -174,6 +188,9 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/pages/operation/operation.vue', 'apps/pda-overseas-uniapp/src/config/runtime.js', 'apps/pda-overseas-uniapp/src/api/auth.js', + 'apps/pda-overseas-uniapp/src/api/inbound.js', + 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', + 'apps/pda-overseas-uniapp/src/api/overseas.js', 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', @@ -187,6 +204,22 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', + 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java', + 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', @@ -197,6 +230,10 @@ $criticalPaths = @( 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', + 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java', 'baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java', @@ -256,5 +293,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs new file mode 100644 index 000000000..180022fb0 --- /dev/null +++ b/scripts/java-topology-contract.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict' +import { access, readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +const sorted = (values) => [...values].sort() + +const parseRootModules = (xml) => { + const body = xml.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...body.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +const parsePom = (xml) => { + const project = xml.replace(/[\s\S]*?<\/parent>/, '') + return { + artifactId: project.match(/([^<]+)<\/artifactId>/)?.[1]?.trim(), + springBoot: /spring-boot-maven-plugin<\/artifactId>/.test(project) + } +} + +const parseServiceMap = (yaml) => { + const services = new Map() + let current + for (const line of yaml.split('\n')) { + const service = line.match(/^ ([a-z][a-z0-9-]+):$/) + if (service) { + current = {} + services.set(service[1], current) + continue + } + const property = line.match(/^ ([a-zA-Z]+): (.+)$/) + if (!current || !property) continue + const [, key, raw] = property + current[key] = key === 'port' + ? Number(raw) + : key === 'regions' + ? raw.slice(1, -1).split(',').map((value) => value.trim()) + : raw + } + return services +} + +const parseGroovyList = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return [...body.matchAll(/'([^']+)'/g)].map((match) => match[1]) +} + +const parseGroovyMap = (source, variable) => { + const body = source.match(new RegExp(`def ${variable} = \\[([\\s\\S]*?)\\n\\]`))?.[1] ?? '' + return new Map([...body.matchAll(/'([^']+)':\s*'([^']+)'/g)] + .map((match) => [match[1], match[2]])) +} + +const parseCompose = (yaml) => { + const body = yaml.match(/^services:\n([\s\S]*?)(?=^networks:)/m)?.[1] ?? '' + const matches = [...body.matchAll(/^ ([a-z][a-z0-9-]+):\s*$/gm)] + return new Map(matches.map((match, index) => { + const block = body.slice(match.index, matches[index + 1]?.index ?? body.length) + return [match[1], Number(block.match(/^ SERVER_PORT: (\d+)$/m)?.[1])] + })) +} + +test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { + const modules = parseRootModules(await read('pom.xml')) + const services = parseServiceMap(await read('deploy/service-map.yml')) + + assert.equal(modules.length, 22) + assert.equal(new Set(modules).size, 22) + assert.equal(services.size, 15) + + const bootModules = [] + for (const module of modules) { + const pom = parsePom(await read(`${module}/pom.xml`)) + if (pom.springBoot) bootModules.push(module) + if (!services.has(module)) continue + const service = services.get(module) + assert.equal(service.artifactId, pom.artifactId, `${module} artifactId drifted`) + assert.equal(service.jar, `${module}/target/${pom.artifactId}-1.0.0-SNAPSHOT.jar`) + } + assert.deepEqual(sorted(bootModules), sorted(services.keys())) + assert.equal(new Set([...services.values()].map(({ port }) => port)).size, services.size) +}) + +test('Jenkins and GitLab package exactly the service-map modules', async () => { + const services = parseServiceMap(await read('deploy/service-map.yml')) + const expected = sorted(services.keys()) + const coordinator = await read('Jenkinsfile') + const servicePipeline = await read('Jenkinsfile.service') + const serviceJars = parseGroovyMap(servicePipeline, 'serviceJars') + const gitlab = await read('.gitlab-ci.yml') + const gitlabArtifacts = [...gitlab.matchAll(/^ - ([a-z][a-z0-9-]+)\/target\/\*\.jar$/gm)] + .map((match) => match[1]) + + assert.deepEqual(sorted(parseGroovyList(coordinator, 'serviceModules')), expected) + assert.deepEqual(sorted(parseGroovyList(servicePipeline, 'serviceModules')), expected) + assert.deepEqual(sorted(serviceJars.keys()), expected) + assert.deepEqual(sorted(gitlabArtifacts), expected) + for (const [module, service] of services) assert.equal(serviceJars.get(module), service.jar) +}) + +test('GitLab runs the topology contract in a dedicated Node job', async () => { + const gitlab = await read('.gitlab-ci.yml') + const topologyJob = gitlab.match(/^Java拓扑合同:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(topologyJob, /^ stage: verify$/m) + assert.match(topologyJob, /^ image: node:[^\s]+$/m) + assert.match(topologyJob, /^ - node --test scripts\/java-topology-contract\.test\.mjs$/m) + assert.match(packageJob, /^ - Java拓扑合同$/m) +}) + +test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { + for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { + const pipeline = await read(path) + if (!pipeline.includes('java-topology-contract.test.mjs')) continue + assert.match( + pipeline, + /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, + `${path} must declare how Node is provided before invoking the topology contract` + ) + } +}) + +test('regional Compose and Nacos manifests cover the service-map topology', async () => { + const services = parseServiceMap(await read('deploy/service-map.yml')) + const composeByRegion = new Map([ + ['CN', parseCompose(await read('deploy/docker-compose.yml'))], + ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))] + ]) + + for (const [module, service] of services) { + const regions = service.regions ?? ['CN'] + for (const [region, compose] of composeByRegion) { + assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`) + if (!regions.includes(region)) continue + assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`) + await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url)) + } + } + assert.deepEqual( + sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), + sorted(services.keys()) + ) +}) diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs new file mode 100644 index 000000000..60c2af78b --- /dev/null +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -0,0 +1,188 @@ +import assert from 'node:assert/strict' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const serviceModules = [ + 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', + 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', + 'warehouse-overseas-service' +] + +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) + +const expectedFeignEdges = new Map(Object.entries({ + 'auth-service->iam-service': 2, + 'file-service->finance-service': 1, + 'file-service->firstleg-service': 1, + 'file-service->iam-service': 2, + 'file-service->integration-service': 1, + 'file-service->order-service': 1, + 'file-service->warehouse-service': 1, + 'finance-service->file-service': 1, + 'finance-service->firstleg-service': 2, + 'finance-service->iam-service': 1, + 'finance-service->warehouse-service': 1, + 'firstleg-service->event-bridge-service': 1, + 'firstleg-service->file-service': 7, + 'firstleg-service->finance-service': 1, + 'firstleg-service->iam-service': 1, + 'firstleg-service->integration-service': 1, + 'firstleg-service->order-service': 2, + 'firstleg-service->print-service': 1, + 'firstleg-service->warehouse-service': 4, + 'iam-service->auth-service': 1, + 'iam-service->event-bridge-service': 1, + 'iam-service->firstleg-service': 1, + 'iam-service->warehouse-service': 1, + 'lastleg-service->event-bridge-service': 1, + 'lastleg-service->warehouse-service': 1, + 'order-service->file-service': 1, + 'order-service->firstleg-service': 3, + 'order-service->iam-service': 1, + 'order-service->warehouse-service': 1 +})) + +const expectedRawHttpImplementations = [ + 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java' +] + +const reviewedRawInternalEdges = [ + 'event-bridge-service->firstleg-service', + 'event-bridge-service->lastleg-service', + 'gateway-overseas->auth-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service' +] + +const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ + +async function javaFiles(directory) { + const result = [] + async function visit(current) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const target = path.join(current, entry.name) + if (entry.isDirectory()) await visit(target) + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) + } + } + await visit(directory) + return result +} + +async function discoverFeignEdges() { + const edges = new Map() + for (const module of serviceModules) { + const sourceRoot = path.join(root, module, 'src', 'main', 'java') + for (const file of await javaFiles(sourceRoot)) { + const source = await readFile(file, 'utf8') + if (!source.includes('@FeignClient')) continue + const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) + const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] + assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) + const edge = `${canonicalSource.get(module)}->${target}` + edges.set(edge, (edges.get(edge) ?? 0) + 1) + } + } + return edges +} + +function sortedEntries(map) { + return [...map.entries()].sort(([left], [right]) => left.localeCompare(right)) +} + +function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) { + lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => { + assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges)) +}) + +test('raw HTTP transports cannot be added without an explicit graph review', async () => { + const discovered = [] + for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (!rawHttpPattern.test(source)) continue + if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + discovered.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort()) +}) + +test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => { + const feignEdges = (await discoverFeignEdges()).keys() + const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges]) + assert.deepEqual(components, [[ + 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', + 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' + ]]) +}) + +test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { + for (const profile of ['local', 'cn', 'overseas']) { + const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') + assert.match(source, /connect-timeout:\s*3000/) + assert.match(source, /read-timeout:\s*5000/) + } + const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') + assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) +}) From 322de03312738ff7b8ace82505575a5c8a34f473 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:53:59 +0800 Subject: [PATCH 157/231] fix(ci): serialize deployments per service --- Jenkinsfile.service | 59 ++++++++++--------- ...est-environment-pipeline-contract.test.mjs | 4 ++ 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 72b8e5f43..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -194,38 +194,43 @@ pipeline { steps { script { String deploySlotLabel = "firstleg-${params.ENVIRONMENT}-deploy-slot" + String serviceDeployResource = "firstleg-${params.ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" String credentialId = "firstleg-${params.ENVIRONMENT}-env" String targetLabel = params.ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 + // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 + lock(resource: serviceDeployResource, inversePrecedence: true) { + lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { + node(targetLabel) { + deleteDir() + try { + unstash 'deployment-files' + echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${params.ENVIRONMENT}" + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] + withCredentials([ + file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, + usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') + ]) { + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_IS_LOCAL=true', + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } } } + } finally { + cleanWs(deleteDirs: true, notFailBuild: true) } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) } } } diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index cb9d380ce..22eaf133c 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -71,13 +71,17 @@ test('development and test deployment locks are acquired before target executors pipeline.indexOf("stage('部署开发或测试环境')"), pipeline.indexOf("stage('部署单个服务')") ) + const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') const nodeIndex = directDeployStage.indexOf('node(targetLabel)') assert.match(directDeployStage, /agent none/) assert.match(directDeployStage, /String targetLabel = params\.ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) + assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{params\.ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) + assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') assert.ok(lockIndex >= 0, 'deployment slot lock must exist') assert.ok(nodeIndex >= 0, 'target node allocation must exist') + assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) From d9c8cc478e2a1bc6f0356f88700dac405af17cd7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 11:54:19 +0800 Subject: [PATCH 158/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../baiyue/firstleg/BaiyueFirstlegApplication.java | 2 +- ...latformCustomerTransitStatisticsController.java | 6 +++--- .../CustomerTransitStatisticsMapper.java | 14 +++++--------- .../CustomerTransitStatisticsService.java | 10 +++++----- .../model}/CustomerTransitStatisticsDtos.java | 2 +- .../port/out/CustomerTransitStatisticsReader.java | 11 +++++++++++ .../BaiyueFirstlegApplicationContractTest.java | 3 ++- .../CustomerTransitStatisticsContractTest.java | 6 +++--- .../CustomerTransitStatisticsServiceTest.java | 10 +++++----- 9 files changed, 36 insertions(+), 28 deletions(-) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{controller => adapter/in/web}/PlatformCustomerTransitStatisticsController.java (83%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{mapper => adapter/out/persistence}/CustomerTransitStatisticsMapper.java (85%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{service => application}/CustomerTransitStatisticsService.java (75%) rename firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/{dto => application/model}/CustomerTransitStatisticsDtos.java (84%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index b9ed1c6ee..8c071e0be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.mapper" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java similarity index 83% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java index ca99b7399..ae441abed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/controller/PlatformCustomerTransitStatisticsController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.controller; +package com.baiyue.firstleg.customerstatistics.adapter.in.web; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; import com.baiyue.firstleg.service.FirstlegScopeGuard; import jakarta.annotation.Resource; import jakarta.validation.constraints.Positive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java similarity index 85% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java index 0c208dc1e..93f276654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/mapper/CustomerTransitStatisticsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java @@ -1,14 +1,13 @@ -package com.baiyue.firstleg.customerstatistics.mapper; +package com.baiyue.firstleg.customerstatistics.adapter.out.persistence; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; -import java.math.BigDecimal; -import java.time.LocalDateTime; - @Mapper -public interface CustomerTransitStatisticsMapper { +public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader { + @Override @Select(""" SELECT #{customerId} AS customerId, COUNT(*) AS containerCount, @@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper { WHERE x.customer_id = #{customerId} ) scoped """) - SummaryRow summarize(@Param("customerId") long customerId); - - record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu, - Long unknownContainerCount, LocalDateTime asOf) {} + SummaryProjection summarize(@Param("customerId") long customerId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java similarity index 75% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java index 485a9572b..71477ce5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/service/CustomerTransitStatisticsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java @@ -1,9 +1,9 @@ -package com.baiyue.firstleg.customerstatistics.service; +package com.baiyue.firstleg.customerstatistics.application; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; @@ -12,11 +12,11 @@ import java.math.BigDecimal; @Service public class CustomerTransitStatisticsService { private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。"; - @Resource private CustomerTransitStatisticsMapper mapper; + @Resource private CustomerTransitStatisticsReader reader; public TransitSummary summarize(long customerId) { if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数"); - var row = mapper.summarize(customerId); + var row = reader.summarize(customerId); return new TransitSummary(customerId, value(row == null ? null : row.containerCount()), row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java similarity index 84% rename from firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java rename to firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java index e123871da..7afb319b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/dto/CustomerTransitStatisticsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.customerstatistics.dto; +package com.baiyue.firstleg.customerstatistics.application.model; import java.math.BigDecimal; import java.time.LocalDateTime; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java new file mode 100644 index 000000000..58ece2751 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.customerstatistics.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public interface CustomerTransitStatisticsReader { + SummaryProjection summarize(long customerId); + + record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu, + Long unknownContainerCount, LocalDateTime asOf) {} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 30474a894..2b9409161 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -20,7 +20,8 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.outbound.dispatch.mapper") .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") - .contains("com.baiyue.firstleg.customerstatistics.mapper"); + .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java index 33463262f..33793badc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsContractTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; -import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController; +import com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java index e7028ebb9..04bf83ec8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerTransitStatisticsServiceTest.java @@ -1,8 +1,8 @@ package com.baiyue.firstleg.customerstatistics; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper; -import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService; +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -19,12 +19,12 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CustomerTransitStatisticsServiceTest { - @Mock CustomerTransitStatisticsMapper mapper; + @Mock CustomerTransitStatisticsReader reader; @InjectMocks CustomerTransitStatisticsService service; @Test void preservesDecimalTeuAndUnknownCount() { var asOf = LocalDateTime.of(2026, 8, 13, 12, 0); - when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow( + when(reader.summarize(88L)).thenReturn(new CustomerTransitStatisticsReader.SummaryProjection( 88L, 4L, new BigDecimal("6.25"), 1L, asOf)); var result = service.summarize(88L); @@ -35,7 +35,7 @@ class CustomerTransitStatisticsServiceTest { assertThat(result.unknownContainerCount()).isEqualTo(1); assertThat(result.asOf()).isEqualTo(asOf); assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型"); - verify(mapper).summarize(88L); + verify(reader).summarize(88L); } @Test void rejectsInvalidCustomerBeforeQuery() { From 24df851acfec2bdf509fdf7c780ba2e703b1ec45 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:18:19 +0800 Subject: [PATCH 159/231] fix(lastleg): register shipping mapper on startup --- deploy/scripts/deploy-built-service.sh | 18 ++++++++++++- .../shipping/ShippingMapperConfiguration.java | 10 ++++++++ .../ShippingMapperConfigurationTest.java | 15 +++++++++++ .../deploy-built-service-rollback.test.mjs | 25 +++++++++++++++++-- 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index dd0aef38d..f20ca9be2 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -222,11 +222,27 @@ local_health_state() { docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || echo missing } +local_runtime_state() { + container_id="$(local_container_id)" + if [ -z "$container_id" ]; then + echo missing + return + fi + docker inspect --format '{{.State.Status}}' "$container_id" 2>/dev/null || echo missing +} + wait_for_local_health() { elapsed=0 while [ "$elapsed" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do + runtime_state="$(local_runtime_state)" health_state="$(local_health_state)" - echo "容器健康状态: $health_state(已等待 ${elapsed}s)" + echo "容器运行状态: $runtime_state,健康状态: $health_state(已等待 ${elapsed}s)" + case "$runtime_state" in + restarting|exited|dead) + echo "容器未保持运行,立即停止等待: $runtime_state" >&2 + return 1 + ;; + esac case "$health_state" in healthy) return 0 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java new file mode 100644 index 000000000..509032dff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/ShippingMapperConfiguration.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.shipping; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers the shipping gate persistence mapper used during event and receiving workflows. */ +@Configuration +@MapperScan(basePackages = "com.baiyue.lastleg.shipping.mapper") +public class ShippingMapperConfiguration { +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java new file mode 100644 index 000000000..34545e9f9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/ShippingMapperConfigurationTest.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.shipping; + +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShippingMapperConfigurationTest { + @Test + void scansShippingMappers() { + MapperScan mapperScan = ShippingMapperConfiguration.class.getAnnotation(MapperScan.class); + + assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.shipping.mapper"); + } +} diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index 84906b7b8..e794a23d3 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -41,8 +41,12 @@ case "$command" in printf '%s\\n' "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" > "$MOCK_CURRENT_IMAGE" if [ "$IMAGE_TAG" = "\${MOCK_FAIL_TAG:-never}" ]; then printf '%s\\n' unhealthy > "$MOCK_HEALTH_STATE" + elif [ "$IMAGE_TAG" = "\${MOCK_RESTART_TAG:-never}" ]; then + printf '%s\\n' starting > "$MOCK_HEALTH_STATE" + printf '%s\\n' restarting > "$MOCK_RUNTIME_STATE" else printf '%s\\n' healthy > "$MOCK_HEALTH_STATE" + printf '%s\\n' running > "$MOCK_RUNTIME_STATE" fi ;; esac @@ -51,6 +55,7 @@ case "$command" in case "$2" in *Config.Image*) cat "$MOCK_CURRENT_IMAGE" ;; *State.Health*) cat "$MOCK_HEALTH_STATE" ;; + *State.Status*) cat "$MOCK_RUNTIME_STATE" ;; *) exit 2 ;; esac ;; @@ -64,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -72,6 +77,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { const logFile = join(temporaryDirectory, 'docker.log') const currentImageFile = join(temporaryDirectory, 'current-image') const healthStateFile = join(temporaryDirectory, 'health-state') + const runtimeStateFile = join(temporaryDirectory, 'runtime-state') await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') @@ -80,6 +86,7 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') + await writeFile(runtimeStateFile, 'running\n', 'utf8') const result = spawnSync(shell, [ deployScript.replaceAll('\\', '/'), @@ -103,7 +110,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag }) { MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, MOCK_FAIL_TAG: failingTag ?? 'never', - MOCK_FAIL_UP_TAG: failingStartTag ?? 'never' + MOCK_FAIL_UP_TAG: failingStartTag ?? 'never', + MOCK_RESTART_TAG: restartingTag ?? 'never', + MOCK_RUNTIME_STATE: runtimeStateFile } }) @@ -152,3 +161,15 @@ test('failed compose start also restores the previous healthy image', async (con assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') }) + +test('restart loops fail fast and restore the previous image', async (context) => { + if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) + + const result = await runDeployment({ imageTag: 'restarting', restartingTag: 'restarting' }) + const output = `${result.stdout}\n${result.stderr}` + + assert.equal(result.status, 1, output) + assert.match(output, /容器未保持运行,立即停止等待: restarting/) + assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') +}) From 0e0b6f8264013c88b855a9cc6a8fbb1fbaabd926 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:22:14 +0800 Subject: [PATCH 160/231] test: add architecture and OpenAPI validation tooling --- .gitignore | 1 + ...rstleg-internal-modularization-baseline.md | 25 ++- firstleg/pom.xml | 1 + .../scripts/analyze-firstleg-dependencies.mjs | 45 ++++- .../analyze-firstleg-dependencies.test.mjs | 23 ++- .../CustomerStatisticsArchitectureTest.java | 80 ++++++++ .../firstleg-slice-dependencies.json | 16 ++ pom.xml | 6 + scripts/java-topology-contract.test.mjs | 18 +- scripts/openapi-validation/package-lock.json | 180 ++++++++++++++++++ scripts/openapi-validation/package.json | 16 ++ .../openapi-validation/validate-openapi.mjs | 83 ++++++++ .../validate-openapi.test.mjs | 67 +++++++ 13 files changed, 544 insertions(+), 17 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java create mode 100644 scripts/openapi-validation/package-lock.json create mode 100644 scripts/openapi-validation/package.json create mode 100644 scripts/openapi-validation/validate-openapi.mjs create mode 100644 scripts/openapi-validation/validate-openapi.test.mjs diff --git a/.gitignore b/.gitignore index 533ecf24c..dc4b3792c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ target/ +node_modules/ .docker-context/ *.log .idea/ diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index 3a37feacd..7dbecb0d3 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -4,8 +4,9 @@ 本基线只分析 `firstleg` 同一部署单元,不拆微服务,不改变业务流程、数据库结构、OpenAPI 或跨服务契约。分析输入为: -- `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 -- `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 +- 首切片实施前 `src/main/java`:429 个 Java 文件;948 条可解析的模块内类依赖;66 个物理包。 +- 首切片实施前 `src/test/java`:169 个 Java 文件。与主源码合并分析时共 598 个 Java 文件、1,635 条模块内类依赖、69 个物理包。 +- 首切片实施后 `src/main/java`:430 个 Java 文件;949 条可解析的模块内类依赖;67 个物理包。增加项仅为只读 Out Port。 可重复命令: @@ -78,7 +79,7 @@ split-loading | 订舱报关拖车 | `oceanbooking/`、`customs/`,全局 `TruckAppointment*`、`WarehouseSupplier*`、出仓门禁贡献者 | 68 | 25/13 | `CustomsRepository` 直接读取入库与装柜 Mapper;订舱 Repository 直接读取全局货柜 Entity | | 出仓 | `outbound/dispatch/` 与全局 `OutboundDispatch*`、运输历史、海外卸柜回写 | 17 | 8/10 | 与装柜、订舱门禁双向;海外回写直接使用全局货柜 Mapper/Entity | | 船节点 | `portschedule/` 中 `CarrierScheduleQuery/Node/Adapter/Attempt/Exception*` | 22 | 8/1 | 查询服务直接读取订舱、船期、货柜 Entity;节点 Outbox 直接使用入库 Outbox Mapper/Entity | -| 客户统计 | `customerstatistics/` | 4 | 1/0 | Java 依赖最低;唯一跨切片依赖为 Controller 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | +| 客户统计 | `customerstatistics/adapter`、`customerstatistics/application` | 5 | 1/0 | 已完成首切片;唯一跨切片依赖为 Web Adapter 调用 `FirstlegScopeGuard`。SQL 只读订舱、货柜和装柜表 | | 财务集成 | 全局 `InternalFinance*`、`FinanceFeeSource*`、`FinanceCostSnapshot*`、付款凭证/快照类 | 20 | 17/6 | 直接读取入库费、柜次费、装柜及共享 Outbox 的 Entity/Mapper;继续受财务总控冻结的业务口径约束 | 共享技术层当前有 36 类,包含应用启动/配置、Scope Guard、单据号、部分跨服务 Client 及无法安全归属的全局模型。跨服务适配器按 `client` 包独立标识,不能因逻辑切片归属而被迁入应用核心。 @@ -104,7 +105,7 @@ split-loading 选择 `customer-statistics`,不是因为预设,而是因为以下实际证据: -1. 仅 4 个生产类,已集中在单一顶层包;没有业务切片对其产生 Java 类型入向依赖。 +1. 实施前仅 4 个生产类且已集中在单一顶层包;实施后为原 4 类加 1 个只读 Out Port,没有业务切片对其产生 Java 类型入向依赖。 2. 唯一跨切片 Java 依赖为 Web Controller 对共享 `FirstlegScopeGuard` 的调用,不涉及 Entity 或 Mapper 泄漏。 3. 不属于当前八节点 SCC,也没有跨服务 Feign/HTTP Client。 4. 现有调用面稳定:一个平台 GET 接口、一个 Admin API 消费者、两个后端测试。 @@ -138,7 +139,7 @@ application -> application.model + common-core ## 7. 精确迁移清单、调用方与测试 -首切片实施时只迁移/新增以下文件: +首切片已按以下清单迁移/新增: | 当前文件 | 目标/动作 | |---|---| @@ -170,7 +171,7 @@ application -> application.model + common-core - `scripts/analyze-firstleg-dependencies.mjs`:生成 JSON/Markdown/DOT 和 Tarjan SCC。 - `src/test/resources/architecture/firstleg-slice-dependencies.json`:冻结当前切片边;历史边可减少,不可增加。 -- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计 4 类、唯一出向边、零入向边和不进入 SCC。 +- `scripts/analyze-firstleg-dependencies.test.mjs`:验证基线无新增边,并锁定客户统计目标 5 类/目录、唯一出向边、零入向边、不进入 SCC 和 application 依赖禁令。 与其他架构专项的依赖: @@ -182,8 +183,18 @@ application -> application.model + common-core ```powershell node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs node firstleg/scripts/analyze-firstleg-dependencies.mjs --source-set main --format markdown +mvn -pl firstleg -am test-compile mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransitStatisticsContractTest -Dsurefire.failIfNoSpecifiedTests=false test ``` -当前只新增只读脚本、合同和报告,没有 Java 签名变化;前两条命令是本轮直接门禁。第三条用于首切片实际迁移后的 Maven 行为验证。 +首切片已经发生 Java 包与依赖签名变化,三条命令均为直接门禁;另需执行 `mvn -pl firstleg -am test-compile` 覆盖全部测试源码调用点。 +## 11. 首切片实施结果 + +- 四个旧包类已删除,没有转发兼容层;目标五层目录已由 Node 合同锁定。 +- `CustomerTransitStatisticsReader.SummaryProjection` 只包含 `customerId`、`containerCount`、`totalTeu`、`unknownContainerCount`、`asOf`,不暴露 MyBatis、Entity 或其他切片类型。 +- 依赖方向由 `application Service -> MyBatis Mapper` 改为 `application Service -> application Port <- persistence Mapper`。 +- 主源码由 429 类/948 条内部依赖/66 包变为 430 类/949 条内部依赖/67 包;36 条切片间边、88 条模型泄漏、31 条 Mapper 泄漏及全局 `mapper <-> repository` SCC 均未增加。客户统计仍只有 `adapter.in.web -> FirstlegScopeGuard` 一条跨切片边,且没有入向边。 +- 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 +- 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 +- 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 02098cbe8..1a35216ef 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -23,6 +23,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.projectlomboklomboktrue org.springframework.bootspring-boot-maven-plugin diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index e2de4acf8..5b252d7a8 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -73,7 +73,7 @@ function classifySlice(fqcn) { function classifyKind(fqcn) { const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); if (packageName.includes('.client')) return 'cross-service-adapter'; - if (/\.(controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/\.(adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; if (/\.(entity|dto)(?:\.|$)/.test(packageName)) return 'data-model'; return 'business-application'; } @@ -218,7 +218,8 @@ function buildReport(sourceSet) { crossSliceMapperLeaks: mapperLeaks.length, }, sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), - classes: sources.map(({ source, imports, ...entry }) => entry), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, packageEdges, sliceEdges, packageSccs, @@ -265,8 +266,43 @@ function checkContract(report, contractPath) { const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const additions = actual.filter(edge => !allowed.has(edge)); - if (additions.length > 0) { - process.stderr.write(`New slice dependencies are not allowed by ${contractPath}:\n${additions.map(edge => `- ${edge}`).join('\n')}\n`); + const violations = additions.map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Firstleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); process.exitCode = 1; } } @@ -281,4 +317,3 @@ const rendered = options.format === 'json' : renderDot(report); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); - diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 9cf71321f..9bcb61d64 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -25,10 +25,11 @@ test('customer statistics remains an independently packaged low-coupling slice', const report = analyze(); const classes = report.classes.filter(entry => entry.slice === 'customer-statistics'); assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ - 'com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController', - 'com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos', - 'com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper', - 'com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService', + 'com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController', + 'com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper', + 'com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService', + 'com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos', + 'com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader', ]); const outgoing = report.sliceEdges.filter(edge => edge.source === 'customer-statistics'); const incoming = report.sliceEdges.filter(edge => edge.target === 'customer-statistics'); @@ -37,3 +38,17 @@ test('customer statistics remains an independently packaged low-coupling slice', assert.equal(report.sliceSccs.some(component => component.includes('customer-statistics')), false); }); +test('customer statistics application layer only depends inward', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.customerstatistics.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java new file mode 100644 index 000000000..252da501c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/customerstatistics/CustomerStatisticsArchitectureTest.java @@ -0,0 +1,80 @@ +package com.baiyue.firstleg.customerstatistics; + +import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class CustomerStatisticsArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics.."; + private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.."; + private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT))) + .andShould().beAssignableTo(CustomerTransitStatisticsReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() { + DescribedPredicate scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") { + @Override + public boolean test(JavaClass javaClass) { + return javaClass.getName().equals(FirstlegScopeGuard.class.getName()); + } + }; + classes().that().resideInAPackage(WEB) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG) + .or(resideInAPackage(APPLICATION)) + .or(resideInAPackage(WEB)) + .or(scopeGuard)) + .check(PRODUCTION_CLASSES); + } + + @Test + void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() { + noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS) + .should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4607d9891..308cda8bc 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -39,6 +39,22 @@ "split-loading->outbound", "split-loading->shared-technical" ], + "requiredClassLocations": { + "com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java", + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", + "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", + "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, "sliceSpecificRules": { "customer-statistics": { "allowedOutgoing": ["shared-technical"], diff --git a/pom.xml b/pom.xml index cbb4a2780..0e706d52c 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,7 @@ 3.13.0 3.4.1 0.8.12 + 1.5.0 2.3.3 @@ -115,6 +116,11 @@ springdoc-openapi-starter-webflux-ui ${springdoc.version} + + com.tngtech.archunit + archunit + ${archunit.version} + diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 180022fb0..736e5e8af 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -117,12 +117,28 @@ test('Jenkins cannot invoke the Node contract without declaring a Node runtime', if (!pipeline.includes('java-topology-contract.test.mjs')) continue assert.match( pipeline, - /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, + /(?:nodejs\s*\(|NodeJSInstallation|docker(?:\.image\([^)]*node:|\s+run[^\n]*\bnode:))/, `${path} must declare how Node is provided before invoking the topology contract` ) } }) +test('Maven verify publishes JaCoCo reports without enforcing a threshold', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const jacoco = pom.match(/\s*org\.jacoco<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + + assert.match(jacoco, /jacoco-maven-plugin<\/artifactId>/) + assert.match(jacoco, /prepare-agent<\/goal>/) + assert.match(jacoco, /verify<\/phase>/) + assert.match(jacoco, /report<\/goal>/) + assert.match(jacoco, /HTML<\/format>/) + assert.match(jacoco, /XML<\/format>/) + assert.doesNotMatch(jacoco, /check<\/goal>|/) + assert.match(gitlab, /^ - "\*\*\/target\/site\/jacoco\/"$/m) + assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) +}) + test('regional Compose and Nacos manifests cover the service-map topology', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) const composeByRegion = new Map([ diff --git a/scripts/openapi-validation/package-lock.json b/scripts/openapi-validation/package-lock.json new file mode 100644 index 000000000..a1127021a --- /dev/null +++ b/scripts/openapi-validation/package-lock.json @@ -0,0 +1,180 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/scripts/openapi-validation/package.json b/scripts/openapi-validation/package.json new file mode 100644 index 000000000..b270dd57e --- /dev/null +++ b/scripts/openapi-validation/package.json @@ -0,0 +1,16 @@ +{ + "name": "baiyue-openapi-validation", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "test": "node --test validate-openapi.test.mjs", + "validate": "node validate-openapi.mjs" + }, + "dependencies": { + "@apidevtools/swagger-parser": "12.1.0" + } +} diff --git a/scripts/openapi-validation/validate-openapi.mjs b/scripts/openapi-validation/validate-openapi.mjs new file mode 100644 index 000000000..16d47d6ed --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.mjs @@ -0,0 +1,83 @@ +import SwaggerParser from '@apidevtools/swagger-parser' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const OPENAPI_DIRECTORY = ['src', 'main', 'resources', 'openapi'].join('/') +const OPENAPI_EXTENSIONS = new Set(['.json', '.yaml', '.yml']) +const EXCLUDED_DIRECTORIES = new Set([ + '.git', '.logs', 'dist', 'node_modules', 'output', 'outputs', 'reports', 'target' +]) + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url))) + +export async function discoverOpenApiFiles(root) { + const files = [] + async function walk(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + for (const entry of entries) { + if (entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name)) continue + const absolute = path.join(directory, entry.name) + if (entry.isDirectory()) { + await walk(absolute) + continue + } + const relative = path.relative(root, absolute).replaceAll(path.sep, '/') + if (relative.includes(`/${OPENAPI_DIRECTORY}/`) + && OPENAPI_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(relative) + } + } + await walk(root) + return files.sort() +} + +export function parseServiceModules(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function summarizeCoverage(files, serviceModules) { + const coveredServices = [...new Set(files.map((file) => file.split('/')[0]))].sort() + const codeFirstServices = serviceModules.filter((service) => !coveredServices.includes(service)) + return { coveredServices, codeFirstServices } +} + +export async function validateRepositoryOpenApi(root = repositoryRoot) { + const files = await discoverOpenApiFiles(root) + if (files.length === 0) throw new Error('No static OpenAPI documents were discovered') + const serviceMap = await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8') + const serviceModules = parseServiceModules(serviceMap.replaceAll('\r\n', '\n')) + const coverage = summarizeCoverage(files, serviceModules) + + const failures = [] + for (const relative of files) { + try { + await SwaggerParser.validate(path.join(root, relative)) + } catch (error) { + failures.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`) + } + } + if (failures.length > 0) { + throw new Error( + `Discovered ${files.length} documents across ${coverage.coveredServices.length}/${serviceModules.length} services; ` + + `code-first gaps: ${coverage.codeFirstServices.join(', ')}.\n` + + `OpenAPI validation failed for ${failures.length}/${files.length} documents:\n` + + failures.map((failure) => `- ${failure}`).join('\n') + ) + } + + return { files, serviceModules, ...coverage } +} + +async function main() { + const result = await validateRepositoryOpenApi() + process.stdout.write(`OpenAPI validation passed: ${result.files.length} documents across ${result.coveredServices.length}/${result.serviceModules.length} services.\n`) + process.stdout.write(`Static OpenAPI services: ${result.coveredServices.join(', ')}\n`) + process.stdout.write(`Code-first services without static documents: ${result.codeFirstServices.join(', ')}\n`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/openapi-validation/validate-openapi.test.mjs b/scripts/openapi-validation/validate-openapi.test.mjs new file mode 100644 index 000000000..4c2eaeb24 --- /dev/null +++ b/scripts/openapi-validation/validate-openapi.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + discoverOpenApiFiles, + parseServiceModules, + summarizeCoverage, + validateRepositoryOpenApi +} from './validate-openapi.mjs' + +test('discovers new static OpenAPI documents without a handwritten file list', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-discovery-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'new-service', 'src', 'main', 'resources', 'openapi') + const ignored = path.join(root, 'new-service', 'target', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(ignored, { recursive: true }) + await writeFile(path.join(openapi, 'new-api.yaml'), 'openapi: 3.0.3\n', 'utf8') + await writeFile(path.join(openapi, 'new-api.json'), '{"openapi":"3.0.3"}\n', 'utf8') + await writeFile(path.join(openapi, 'notes.md'), 'not an OpenAPI document\n', 'utf8') + await writeFile(path.join(ignored, 'ignored.yaml'), 'openapi: 3.0.3\n', 'utf8') + + assert.deepEqual(await discoverOpenApiFiles(root), [ + 'new-service/src/main/resources/openapi/new-api.json', + 'new-service/src/main/resources/openapi/new-api.yaml' + ]) +}) + +test('derives static and code-first service coverage from service-map', () => { + const serviceModules = parseServiceModules('services:\n alpha:\n port: 1\n beta:\n port: 2\n') + assert.deepEqual(serviceModules, ['alpha', 'beta']) + assert.deepEqual(summarizeCoverage([ + 'alpha/src/main/resources/openapi/alpha.yaml' + ], serviceModules), { + coveredServices: ['alpha'], + codeFirstServices: ['beta'] + }) +}) + +test('rejects an unresolved OpenAPI reference', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-openapi-ref-')) + t.after(() => rm(root, { recursive: true, force: true })) + const openapi = path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi') + await mkdir(openapi, { recursive: true }) + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n', 'utf8') + await writeFile(path.join(openapi, 'broken.yaml'), `openapi: 3.0.3 +info: + title: Broken API + version: 1.0.0 +paths: + /broken: + get: + responses: + '200': + description: broken + content: + application/json: + schema: + $ref: '#/components/schemas/Missing' +`, 'utf8') + + await assert.rejects(validateRepositoryOpenApi(root), /Missing \$ref pointer/) +}) From 4b5e5a1e4e706891ca9987876f3b69a0966a635a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:27:47 +0800 Subject: [PATCH 161/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 10 ++++ .../openapi/carrier-schedule-query-v1.yaml | 2 +- .../resources/openapi/fba-pricing-v1.yaml | 48 +++++++++++++++-- .../main/resources/openapi/firstleg-v1.yaml | 1 - .../openapi/phase3-loading-delivery-v1.yaml | 2 +- .../openapi/phase3-pda-convergence-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 16 +++--- .../controller/FbaPricingContractTest.java | 51 +++++++++++++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../resources/openapi/notification-v1.yaml | 28 ++++++---- .../NotificationOpenApiContractTest.java | 27 ++++++++++ .../src/main/resources/openapi/order-v1.yaml | 11 ++-- scripts/java-topology-contract.test.mjs | 16 ++++++ 13 files changed, 183 insertions(+), 33 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4c67d8bc7..f05dade2c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,15 @@ Java拓扑合同: script: - node --test scripts/java-topology-contract.test.mjs +OpenAPI有效性: + stage: verify + image: node:22-alpine + cache: [] + script: + - npm ci --ignore-scripts --prefix scripts/openapi-validation + - npm test --prefix scripts/openapi-validation + - npm run validate --prefix scripts/openapi-validation + 校验与测试: stage: verify script: @@ -39,6 +48,7 @@ Java拓扑合同: stage: package needs: - Java拓扑合同 + - OpenAPI有效性 - 校验与测试 script: - mvn $MAVEN_CLI_OPTS -DskipTests package diff --git a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml index 5cf380ab9..4f6dd8f73 100644 --- a/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml +++ b/firstleg/src/main/resources/openapi/carrier-schedule-query-v1.yaml @@ -9,7 +9,7 @@ paths: summary: List official carrier sources and compliant access modes x-permission: platform:firstleg:port-schedule-query:list responses: - '200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors} + '200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'} /api/platform/v1/carrier-schedule-queries: get: summary: Query carrier schedule tasks diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index c7a74e31f..aa91a88be 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -18,7 +18,7 @@ paths: description: 成功或业务失败 content: application/json: - schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'} + schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'} '403': {description: 无权限} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -231,6 +231,44 @@ components: VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: + CustomerRuleVersionListApiResponse: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/CustomerRuleVersionResource'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + CustomerRuleVersionResource: + type: object + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + ruleCode: {type: string} + ruleVersion: {type: integer} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + businessType: {type: string} + chargeMode: {type: string} + taxMode: {type: string} + conversionFactor: {type: number} + roundingScale: {type: integer} + roundingMode: {type: string} + currency: {type: string} + effectiveFrom: {type: string, format: date-time} + effectiveTo: {type: string, format: date-time, nullable: true} + status: {type: string} + remark: {type: string, nullable: true} + createdBy: {type: string, nullable: true} + createdAt: {type: string, format: date-time, nullable: true} + updatedBy: {type: string, nullable: true} + updatedAt: {type: string, format: date-time, nullable: true} + deleted: {type: boolean, nullable: true} + version: {type: integer, nullable: true} FeeSnapshotApiResponse: type: object required: [code, message, data] @@ -415,7 +453,7 @@ components: required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason] properties: expectedFeeVersion: {type: integer, minimum: 1} - chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit} + chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit} unitPrice: {type: number, minimum: 0, description: 人工确认单价} additionalAmount: {type: number, minimum: 0, multipleOf: 0.01} reason: {type: string, minLength: 1, maxLength: 500} @@ -432,7 +470,7 @@ components: businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]} - conversionFactor: {type: number, exclusiveMinimum: 0, example: 167} + conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167} roundingScale: {type: integer, minimum: 0, maximum: 4} roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]} currency: {type: string, pattern: '^[A-Z]{3}$'} @@ -454,7 +492,7 @@ components: destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} destinationName: {type: string, maxLength: 128, nullable: true} chargeUnit: {type: string, enum: [KG, CBM]} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} additionalFee: {type: number, minimum: 0} effectiveFrom: {type: string, format: date-time} @@ -578,7 +616,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} chargeUnit: {type: string, enum: [KG, CBM]} weightFromKg: {type: number, minimum: 0, nullable: true} - unitPrice: {type: number, exclusiveMinimum: 0} + unitPrice: {type: number, minimum: 0, exclusiveMinimum: true} currency: {type: string, pattern: '^[A-Z]{3}$'} sourceSheetName: {type: string, maxLength: 128} sourceRowNumber: {type: integer, minimum: 1} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f38dfe72d..652cd8003 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1112,7 +1112,6 @@ components: notificationStatus: {type: string, nullable: true} version: {type: integer, nullable: true} expectedArrivalAt: {type: string, format: date-time, nullable: true} - requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at} customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} companyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index 60e832d45..3b70a9548 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -34,7 +34,7 @@ paths: content: application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}} responses: - '200': {description: Business envelope; ownership, type and version failures use business error codes} + '200': {description: 'Business envelope; ownership, type and version failures use business error codes'} /api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda: get: summary: Download one loading evidence photo for the active PDA worker diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index eef1c0492..165d36799 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -93,7 +93,7 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}} responses: - '200': {description: Completed task, identical replay result, or A0409 when any snapshot changed} + '200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review: post: summary: 人工确认装柜完成的兼容入口 diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4a953b89d..0d2355f8f 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -8,7 +8,7 @@ paths: get: summary: Query truck appointments parameters: - - {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: '200': description: Paged truck appointments with stable booking/container snapshots and attachment count @@ -29,13 +29,13 @@ paths: application/json: schema: {$ref: '#/components/schemas/SaveRequest'} responses: - '200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} + '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query a truck appointment responses: - '200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} + '200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}} put: summary: Update an editable truck appointment with optimistic locking requestBody: @@ -84,20 +84,20 @@ paths: get: summary: Query immutable appointment status history responses: - '200': {description: History entries including action, from/to status, operator, reason and time} + '200': {description: 'History entries including action, from/to status, operator, reason and time'} /api/platform/v1/truck-appointments/{id}/attachments: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: summary: Query attachment references owned by the appointment description: 'Requires platform:firstleg:truck-appointment:list.' responses: - '200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} + '200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}} post: summary: Reference a file already owned by the appointment customer and business scope description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}} responses: - '200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} + '200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}} /api/platform/v1/truck-appointments/{id}/attachments/upload: parameters: [{$ref: '#/components/parameters/AppointmentId'}] post: @@ -181,8 +181,8 @@ components: required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} - oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} - containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 956e14f4a..a3362b6cc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.web.bind.annotation.RequestParam; +import org.yaml.snakeyaml.Yaml; import java.nio.charset.StandardCharsets; import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -82,9 +85,57 @@ class FbaPricingContractTest { .doesNotContain("format: int64"); } + @Test + @DisplayName("客户规则列表响应锁定数组包络和实体核心字段") + void freezesCustomerRuleVersionListResponse() throws Exception { + String source = new ClassPathResource("openapi/fba-pricing-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map api = map(new Yaml().load(source)); + Map paths = map(api.get("paths")); + Map get = map(map(paths.get( + "/api/platform/v1/fba-pricing/customer-rules")).get("get")); + Map success = map(map(get.get("responses")).get("200")); + Map responseSchema = map(map(map(success.get("content")) + .get("application/json")).get("schema")); + assertThat(responseSchema).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionListApiResponse"); + + Map schemas = map(map(api.get("components")).get("schemas")); + Map envelope = map(schemas.get("CustomerRuleVersionListApiResponse")); + assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp"); + Map envelopeProperties = map(envelope.get("properties")); + assertThat(envelopeProperties).containsOnlyKeys( + "code", "message", "data", "requestId", "traceId", "timestamp"); + Map data = map(envelopeProperties.get("data")); + assertThat(data).containsEntry("type", "array"); + assertThat(map(data.get("items"))).containsEntry( + "$ref", "#/components/schemas/CustomerRuleVersionResource"); + + Map resource = map(schemas.get("CustomerRuleVersionResource")); + assertThat(map(resource.get("properties"))).containsOnlyKeys( + "id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName", + "businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale", + "roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark", + "createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version"); + for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) { + assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string"); + } + assertThat(source).doesNotContain("CustomerRuleVersionSummary"); + } + private static Method method(String name) { return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(name)) .findFirst().orElseThrow(); } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Object value) { + return (List) value; + } } diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index fe88bdc06..4fa22a7e6 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -886,7 +886,7 @@ components: TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}} BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} - Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} + Forbidden: {description: 'Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配', content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}} diff --git a/iam/src/main/resources/openapi/notification-v1.yaml b/iam/src/main/resources/openapi/notification-v1.yaml index 99f5f992f..e184472d0 100644 --- a/iam/src/main/resources/openapi/notification-v1.yaml +++ b/iam/src/main/resources/openapi/notification-v1.yaml @@ -60,7 +60,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: templateCode, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match template name or title template, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match template name or title template', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}} responses: {'200': {$ref: '#/components/responses/TemplatePage'}} post: @@ -92,7 +92,7 @@ paths: - {$ref: '#/components/parameters/Size'} - {name: taskNo, in: query, schema: {type: string, maxLength: 64}} - {name: category, in: query, schema: {type: string, maxLength: 64}} - - {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}} + - {name: title, in: query, description: 'Fuzzy match rendered title', schema: {type: string, maxLength: 128}} - {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}} responses: {'200': {$ref: '#/components/responses/TaskPage'}} /platform/v1/notification-tasks/{id}: @@ -145,17 +145,27 @@ components: tenantId: {type: integer, format: int64, minimum: 0} templateCode: {type: string, maxLength: 64} recipientType: {type: string, enum: [USER, MEMBERSHIP, TENANT_ROLE, TENANT_ALL, PLATFORM_ROLE]} - recipientIds: {type: array, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} - roleCode: {type: string, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} + recipientIds: {type: array, minItems: 1, maxItems: 1000, items: {type: integer, format: int64}, description: Required for USER or MEMBERSHIP; forbidden for role/all recipient types.} + roleCode: {type: string, minLength: 1, maxLength: 64, description: Required for TENANT_ROLE or PLATFORM_ROLE; forbidden otherwise.} variables: {type: object, maxProperties: 32, additionalProperties: {type: string, maxLength: 2000}} scheduledAt: {type: string, format: date-time, nullable: true} idempotencyKey: {type: string, minLength: 8, maxLength: 128} sourceEventId: {type: string, maxLength: 128, nullable: true} - allOf: - - if: {properties: {recipientType: {enum: [USER, MEMBERSHIP]}}} - then: {required: [recipientIds]} - - if: {properties: {recipientType: {enum: [TENANT_ROLE, PLATFORM_ROLE]}}} - then: {required: [roleCode]} + oneOf: + - required: [recipientIds] + properties: + recipientType: {type: string, enum: [USER, MEMBERSHIP]} + not: {required: [roleCode]} + - required: [roleCode] + properties: + recipientType: {type: string, enum: [TENANT_ROLE, PLATFORM_ROLE]} + not: {required: [recipientIds]} + - properties: + recipientType: {type: string, enum: [TENANT_ALL]} + not: + anyOf: + - required: [recipientIds] + - required: [roleCode] CreateTemplateRequest: type: object required: [templateCode, templateName, category, titleTemplate, bodyTemplate, variableWhitelist] diff --git a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java index af6a9a172..d514112cd 100644 --- a/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java @@ -69,6 +69,33 @@ class NotificationOpenApiContractTest { assertThat(map(map(taskDetail.get("properties")).get("attemptCount"))) .containsEntry("minimum", 0).containsEntry("maximum", 3); assertThat(document).contains("scheduler delivery starts after the retry delay"); + + Map createNotification = map(schemas.get("CreateNotificationRequest")); + Map selectorProperties = map(createNotification.get("properties")); + assertThat(map(selectorProperties.get("recipientIds"))).containsEntry("minItems", 1); + assertThat(map(selectorProperties.get("roleCode"))).containsEntry("minLength", 1); + List selectorBranches = list(createNotification.get("oneOf")); + assertThat(selectorBranches).hasSize(3); + + Map directRecipients = map(selectorBranches.get(0)); + assertThat(list(directRecipients.get("required"))).containsExactly("recipientIds"); + assertThat(list(map(directRecipients.get("not")).get("required"))).containsExactly("roleCode"); + assertThat(list(map(map(directRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("USER", "MEMBERSHIP"); + + Map roleRecipients = map(selectorBranches.get(1)); + assertThat(list(roleRecipients.get("required"))).containsExactly("roleCode"); + assertThat(list(map(roleRecipients.get("not")).get("required"))).containsExactly("recipientIds"); + assertThat(list(map(map(roleRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ROLE", "PLATFORM_ROLE"); + + Map allRecipients = map(selectorBranches.get(2)); + assertThat(list(map(map(allRecipients.get("properties")).get("recipientType")).get("enum"))) + .containsExactly("TENANT_ALL"); + assertThat(list(map(allRecipients.get("not")).get("anyOf"))) + .extracting(value -> list(map(value).get("required"))) + .containsExactly(List.of("recipientIds"), List.of("roleCode")); + assertThat(document).doesNotContain("if:").doesNotContain("then:"); } private void assertRequestBody(Map paths, String path, String method) { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b015fdb8b..114b1e06e 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -379,6 +379,11 @@ paths: '403': {description: 需要 merchant:order:view 权限} components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT schemas: TenantOrderStatisticsResponse: type: object @@ -391,12 +396,6 @@ components: nextMonthStart: {type: string, format: date-time} zoneId: {type: string, example: Asia/Shanghai} asOf: {type: string, format: date-time} - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - schemas: UnifiedMainStatus: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 736e5e8af..371e7053f 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -111,6 +111,22 @@ test('GitLab runs the topology contract in a dedicated Node job', async () => { assert.match(packageJob, /^ - Java拓扑合同$/m) }) +test('GitLab validates every static OpenAPI document before packaging', async () => { + const gitlab = await read('.gitlab-ci.yml') + const openapiJob = gitlab.match(/^OpenAPI有效性:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(openapiJob, /^ stage: verify$/m) + assert.match(openapiJob, /^ image: node:[^\s]+$/m) + assert.match(openapiJob, /^ - npm ci --ignore-scripts --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm test --prefix scripts\/openapi-validation$/m) + assert.match(openapiJob, /^ - npm run validate --prefix scripts\/openapi-validation$/m) + assert.doesNotMatch(openapiJob, /\bnpx\b|@latest/) + assert.match(packageJob, /^ - Java拓扑合同$/m) + assert.match(packageJob, /^ - OpenAPI有效性$/m) + assert.match(packageJob, /^ - 校验与测试$/m) +}) + test('Jenkins cannot invoke the Node contract without declaring a Node runtime', async () => { for (const path of ['Jenkinsfile', 'Jenkinsfile.service']) { const pipeline = await read(path) From 12dad6673df6dec110ef778c900fca16af2d38f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:29:47 +0800 Subject: [PATCH 162/231] fix(lastleg): register PDA operation mappers --- .../pda/PdaOperationsMapperConfiguration.java | 15 ++++++++++++++ .../PdaOperationsMapperConfigurationTest.java | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java new file mode 100644 index 000000000..56392c5f7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfiguration.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.pda; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +/** Registers mapper packages used by the overseas PDA warehouse operation workflows. */ +@Configuration +@MapperScan(basePackages = { + "com.baiyue.lastleg.palletops.mapper", + "com.baiyue.lastleg.pda.move.mapper", + "com.baiyue.lastleg.pda.stocktake.mapper", + "com.baiyue.lastleg.pda.warehouse.mapper" +}) +public class PdaOperationsMapperConfiguration { +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java new file mode 100644 index 000000000..91c76f380 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsMapperConfigurationTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.pda; + +import org.junit.jupiter.api.Test; +import org.mybatis.spring.annotation.MapperScan; + +import static org.assertj.core.api.Assertions.assertThat; + +class PdaOperationsMapperConfigurationTest { + @Test + void scansEveryPdaWarehouseOperationMapperPackage() { + MapperScan mapperScan = PdaOperationsMapperConfiguration.class.getAnnotation(MapperScan.class); + + assertThat(mapperScan.basePackages()).containsExactlyInAnyOrder( + "com.baiyue.lastleg.palletops.mapper", + "com.baiyue.lastleg.pda.move.mapper", + "com.baiyue.lastleg.pda.stocktake.mapper", + "com.baiyue.lastleg.pda.warehouse.mapper" + ); + } +} From 1382902f86e74ba5fd75d9217549737a6168bb7a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:30:07 +0800 Subject: [PATCH 163/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-web/AGENTS.md | 2 + .../baiyue/common/web/CorrelationContext.java | 11 + .../common/web/CorrelationIdFilter.java | 53 +++- .../com/baiyue/common/web/TraceParent.java | 84 +++++ .../web/CommonWebAutoConfigurationTest.java | 4 + .../common/web/CorrelationIdFilterTest.java | 74 ++++- .../baiyue/common/web/TraceParentTest.java | 46 +++ config/nacos/overseas/lastleg-service.yml | 14 + .../scripts/analyze-lastleg-dependencies.mjs | 295 ++++++++++++++++++ .../config/LastlegRestClientFactory.java | 21 ++ .../config/LastlegRestClientProperties.java | 54 ++++ .../gateway/HttpUnloadPhotoFileGateway.java | 9 +- .../gateway/HttpWarehouseResourceGateway.java | 7 +- .../gateway/HttpPalletPhysicalGateway.java | 9 +- .../gateway/HttpRelabelPhotoFileGateway.java | 9 +- .../src/main/resources/application.yml | 14 + 16 files changed, 687 insertions(+), 19 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/TraceParent.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java create mode 100644 lastleg-service/scripts/analyze-lastleg-dependencies.mjs create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 23b2f7310..19fdeacb0 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -11,6 +11,7 @@ |---|---| | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | +| W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -23,5 +24,6 @@ - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 +- 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java index 976ab0b30..a38dfaf80 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationContext.java @@ -9,8 +9,11 @@ import org.slf4j.MDC; public final class CorrelationContext { public static final String REQUEST_ID = "requestId"; public static final String TRACE_ID = "traceId"; + public static final String SPAN_ID = "spanId"; + public static final String TRACE_PARENT = "traceparent"; public static final String REQUEST_ID_HEADER = "X-Request-Id"; public static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String TRACE_PARENT_HEADER = TraceParent.HEADER; private CorrelationContext() {} @@ -22,6 +25,14 @@ public final class CorrelationContext { return value(request, TRACE_ID); } + public static String spanId(HttpServletRequest request) { + return value(request, SPAN_ID); + } + + public static String traceParent(HttpServletRequest request) { + return value(request, TRACE_PARENT); + } + private static String value(HttpServletRequest request, String key) { Object attribute = request.getAttribute(key); return attribute == null ? MDC.get(key) : attribute.toString(); diff --git a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java index b08f7c832..f9e39379d 100644 --- a/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java +++ b/common-web/src/main/java/com/baiyue/common/web/CorrelationIdFilter.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.Enumeration; import java.util.UUID; import java.util.regex.Pattern; @@ -25,24 +26,60 @@ public class CorrelationIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String requestId = acceptedOrGenerated(request.getHeader(CorrelationContext.REQUEST_ID_HEADER), false); - String traceId = acceptedOrGenerated(request.getHeader(CorrelationContext.TRACE_ID_HEADER), true); + String requestId = requestId(request); + TraceParent traceParent = traceParent(request); + String traceId = traceParent.traceId(); request.setAttribute(CorrelationContext.REQUEST_ID, requestId); request.setAttribute(CorrelationContext.TRACE_ID, traceId); + request.setAttribute(CorrelationContext.SPAN_ID, traceParent.parentId()); + request.setAttribute(CorrelationContext.TRACE_PARENT, traceParent.value()); response.setHeader(CorrelationContext.REQUEST_ID_HEADER, requestId); response.setHeader(CorrelationContext.TRACE_ID_HEADER, traceId); + response.setHeader(CorrelationContext.TRACE_PARENT_HEADER, traceParent.value()); try (MDC.MDCCloseable ignoredRequest = MDC.putCloseable(CorrelationContext.REQUEST_ID, requestId); - MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId)) { + MDC.MDCCloseable ignoredTrace = MDC.putCloseable(CorrelationContext.TRACE_ID, traceId); + MDC.MDCCloseable ignoredSpan = MDC.putCloseable(CorrelationContext.SPAN_ID, traceParent.parentId())) { filterChain.doFilter(request, response); } } - private String acceptedOrGenerated(String candidate, boolean compact) { - if (candidate != null && SAFE_ID.matcher(candidate).matches()) { - return candidate; + private String requestId(HttpServletRequest request) { + HeaderValue header = singleHeader(request, CorrelationContext.REQUEST_ID_HEADER, 128); + if (header.valid() && SAFE_ID.matcher(header.value()).matches()) { + return header.value(); + } + return UUID.randomUUID().toString(); + } + + private TraceParent traceParent(HttpServletRequest request) { + HeaderValue standard = singleHeader(request, CorrelationContext.TRACE_PARENT_HEADER, + TraceParent.HEADER_LENGTH); + if (standard.present()) { + if (standard.valid()) { + return TraceParent.parse(standard.value()).map(TraceParent::childOf) + .orElseGet(() -> TraceParent.newRoot(null)); + } + return TraceParent.newRoot(null); + } + + HeaderValue legacy = singleHeader(request, CorrelationContext.TRACE_ID_HEADER, 32); + return TraceParent.newRoot(legacy.valid() ? legacy.value() : null); + } + + private HeaderValue singleHeader(HttpServletRequest request, String name, int maxLength) { + Enumeration values = request.getHeaders(name); + if (values == null || !values.hasMoreElements()) { + return HeaderValue.missing(); + } + String value = values.nextElement(); + boolean duplicate = values.hasMoreElements(); + return new HeaderValue(true, !duplicate && value != null && value.length() <= maxLength, value); + } + + private record HeaderValue(boolean present, boolean valid, String value) { + private static HeaderValue missing() { + return new HeaderValue(false, false, null); } - String id = UUID.randomUUID().toString(); - return compact ? id.replace("-", "") : id; } } diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java new file mode 100644 index 000000000..2fbd3a1ab --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -0,0 +1,84 @@ +package com.baiyue.common.web; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Strict W3C {@code traceparent} version 00 value. + * + *

This type deliberately accepts only the currently frozen version-00 shape. Unsupported, + * ambiguous or malformed input must start a new trace instead of being partially trusted.

+ */ +public record TraceParent(String traceId, String parentId, String traceFlags) { + public static final String HEADER = "traceparent"; + public static final int HEADER_LENGTH = 55; + + private static final Pattern VERSION_00 = Pattern.compile( + "00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})"); + private static final Pattern LEGACY_TRACE_ID = Pattern.compile("[0-9A-Fa-f]{32}"); + private static final String ZERO_TRACE_ID = "0".repeat(32); + private static final String ZERO_PARENT_ID = "0".repeat(16); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final HexFormat HEX = HexFormat.of(); + + public TraceParent { + if (!validTraceId(traceId) || !validParentId(parentId) + || traceFlags == null || !traceFlags.matches("[0-9a-f]{2}")) { + throw new IllegalArgumentException("Invalid W3C traceparent components"); + } + } + + /** Parses one exact, lowercase W3C version-00 header value. */ + public static Optional parse(String value) { + if (value == null || value.length() != HEADER_LENGTH) { + return Optional.empty(); + } + Matcher matcher = VERSION_00.matcher(value); + if (!matcher.matches() || !validTraceId(matcher.group(1)) || !validParentId(matcher.group(2))) { + return Optional.empty(); + } + return Optional.of(new TraceParent(matcher.group(1), matcher.group(2), matcher.group(3))); + } + + /** Creates the local inbound context for a valid remote parent. */ + public static TraceParent childOf(TraceParent remoteParent) { + return new TraceParent(remoteParent.traceId(), randomNonZero(8), remoteParent.traceFlags()); + } + + /** Creates a new root context, optionally reusing a valid legacy trace ID. */ + public static TraceParent newRoot(String legacyTraceId) { + String traceId = validLegacyTraceId(legacyTraceId) + ? legacyTraceId.toLowerCase(java.util.Locale.ROOT) : randomNonZero(16); + return new TraceParent(traceId, randomNonZero(8), "00"); + } + + public static boolean validLegacyTraceId(String value) { + return value != null && LEGACY_TRACE_ID.matcher(value).matches() + && !ZERO_TRACE_ID.equalsIgnoreCase(value); + } + + public String value() { + return "00-" + traceId + '-' + parentId + '-' + traceFlags; + } + + private static boolean validTraceId(String value) { + return value != null && value.matches("[0-9a-f]{32}") && !ZERO_TRACE_ID.equals(value); + } + + private static boolean validParentId(String value) { + return value != null && value.matches("[0-9a-f]{16}") && !ZERO_PARENT_ID.equals(value); + } + + private static String randomNonZero(int byteCount) { + byte[] bytes = new byte[byteCount]; + String value; + do { + RANDOM.nextBytes(bytes); + value = HEX.formatHex(bytes); + } while (value.chars().allMatch(character -> character == '0')); + return value; + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java index bab2952df..08739af98 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CommonWebAutoConfigurationTest.java @@ -1,6 +1,8 @@ package com.baiyue.common.web; import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -16,6 +18,8 @@ class CommonWebAutoConfigurationTest { contextRunner.run(context -> { assertThat(context).hasSingleBean(GlobalExceptionHandler.class); assertThat(context).hasSingleBean(CorrelationIdFilter.class); + assertThat(CorrelationIdFilter.class.getAnnotation(Order.class).value()) + .isEqualTo(Ordered.HIGHEST_PRECEDENCE); }); } diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index 08bb2e583..c73051fbe 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -6,6 +6,8 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import java.io.IOException; + import static org.assertj.core.api.Assertions.assertThat; @DisplayName("请求链路标识过滤功能") @@ -13,27 +15,85 @@ class CorrelationIdFilterTest { private final CorrelationIdFilter filter = new CorrelationIdFilter(); @Test - void preservesSafeIncomingIds() throws Exception { + void validTraceParentWinsAndMapsToLegacyTraceIdAndMdc() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "req-123"); - request.addHeader(CorrelationContext.TRACE_ID_HEADER, "trace-123"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(request, response, new MockFilterChain()); + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilter(request, response, chain); assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)).isEqualTo("req-123"); - assertThat(request.getAttribute(CorrelationContext.TRACE_ID)).isEqualTo("trace-123"); + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); } @Test - void replacesUnsafeIncomingIds() throws Exception { + void fallsBackToValidLegacyTraceIdOnlyWhenTraceParentIsMissing() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "invalid value\n"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "ABCDEF0123456789ABCDEF0123456789"); MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(response.getHeader(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } + + @Test + void invalidTraceParentStartsNewTraceWithoutTrustingLegacyTraceId() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-00000000000000000000000000000000-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .isNotEqualTo("00000000000000000000000000000000"); + } + + @Test + void rejectsDuplicateAndOversizedHeaders() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(CorrelationContext.REQUEST_ID_HEADER, "a".repeat(129)); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"); + request.addHeader(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + assertThat(response.getHeader(CorrelationContext.REQUEST_ID_HEADER)) .matches("[0-9a-f-]{36}"); assertThat(response.getHeader(CorrelationContext.TRACE_ID_HEADER)) - .matches("[0-9a-f]{32}"); + .matches("[0-9a-f]{32}") + .isNotEqualTo("4bf92f3577b34da6a3ce929d0e0e4736") + .isNotEqualTo("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + } + + private static final class CapturingFilterChain extends MockFilterChain { + private String traceId; + private String spanId; + + @Override + public void doFilter(jakarta.servlet.ServletRequest request, + jakarta.servlet.ServletResponse response) throws IOException { + traceId = org.slf4j.MDC.get(CorrelationContext.TRACE_ID); + spanId = org.slf4j.MDC.get(CorrelationContext.SPAN_ID); + } } } diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java new file mode 100644 index 000000000..c97edfa34 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -0,0 +1,46 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TraceParentTest { + + @Test + void parsesCanonicalVersion00AndCreatesLocalChild() { + TraceParent remote = TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").orElseThrow(); + + TraceParent local = TraceParent.childOf(remote); + + assertThat(local.traceId()).isEqualTo(remote.traceId()); + assertThat(local.parentId()).matches("[0-9a-f]{16}") + .isNotEqualTo(remote.parentId()) + .isNotEqualTo("0000000000000000"); + assertThat(local.traceFlags()).isEqualTo("01"); + assertThat(local.value()).hasSize(TraceParent.HEADER_LENGTH); + } + + @Test + void rejectsNonCanonicalUnsupportedAndZeroIdentifiers() { + assertThat(TraceParent.parse( + "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-00000000000000000000000000000000-00f067aa0ba902b7-01")).isEmpty(); + assertThat(TraceParent.parse( + "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01")).isEmpty(); + assertThat(TraceParent.parse("x".repeat(10_000))).isEmpty(); + } + + @Test + void createsCanonicalNonZeroRootAndNormalizesLegacyHex() { + TraceParent root = TraceParent.newRoot("ABCDEF0123456789ABCDEF0123456789"); + + assertThat(root.traceId()).isEqualTo("abcdef0123456789abcdef0123456789"); + assertThat(root.parentId()).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(root.value()).matches( + "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); + } +} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index e6241c4b6..c4d919be3 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -10,6 +10,20 @@ lastleg: smart-dispatch-api: enabled: true baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL} diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs new file mode 100644 index 000000000..378e380e8 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const sourceRoots = { + main: path.join(moduleRoot, 'src', 'main', 'java'), + test: path.join(moduleRoot, 'src', 'test', 'java'), +}; +const internalPrefix = 'com.baiyue.lastleg.'; + +const sliceRules = [ + ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], + ['inventory', /(?:^|\.)inventory(?:\.|$)/], + ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], + ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], + ['dropship', /(?:^|\.)dropship(?:\.|$)/], + ['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/], + ['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/], + ['tailleg-handoff', /(?:^|\.)(?:controller|dto|entity|mapper|model|repository|service|shipping)(?:\.|$)/], +]; + +function parseArgs(argv) { + const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--source-set') options.sourceSet = argv[++index]; + else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--output') options.output = argv[++index]; + else if (argument === '--check') options.check = argv[++index]; + else if (argument === '--help') { + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.exit(0); + } else throw new Error(`Unknown argument: ${argument}`); + } + if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); + if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + return options; +} + +function walkJavaFiles(root) { + if (!fs.existsSync(root)) return []; + const result = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(absolute); + else if (entry.isFile() && entry.name.endsWith('.java')) result.push(absolute); + } + } + return result.sort(); +} + +function stripCommentsAndStrings(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\r\n]*/g, ' ') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/'(?:\\.|[^'\\])*'/g, "''"); +} + +function classifySlice(fqcn) { + for (const [slice, expression] of sliceRules) { + if (expression.test(fqcn)) return slice; + } + return 'shared-technical'; +} + +function classifyKind(fqcn) { + const packageName = fqcn.slice(0, fqcn.lastIndexOf('.')); + if (/(?:^|\.)(?:client|gateway)(?:\.|$)/.test(packageName)) return 'cross-service-adapter'; + if (/(?:^|\.)(?:adapter|controller|repository|mapper|config|security)(?:\.|$)/.test(packageName)) return 'technical-adapter'; + if (/(?:^|\.)(?:entity|dto|model)(?:\.|$)/.test(packageName)) return 'data-model'; + return 'business-application'; +} + +function parseSource(file, sourceSet) { + const raw = fs.readFileSync(file, 'utf8'); + const source = stripCommentsAndStrings(raw); + const packageName = source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1]; + const typeName = path.basename(file, '.java'); + if (!packageName) throw new Error(`Missing package declaration: ${file}`); + const fqcn = `${packageName}.${typeName}`; + const imports = [...source.matchAll(/\bimport\s+(?:static\s+)?([\w.]+)(?:\.\*)?\s*;/g)].map(match => match[1]); + return { + file: path.relative(moduleRoot, file).replaceAll(path.sep, '/'), + sourceSet, + packageName, + typeName, + fqcn, + imports, + source, + slice: classifySlice(fqcn), + kind: classifyKind(fqcn), + }; +} + +function collectSources(sourceSet) { + const selected = sourceSet === 'all' ? ['main', 'test'] : [sourceSet]; + return selected.flatMap(set => walkJavaFiles(sourceRoots[set]).map(file => parseSource(file, set))); +} + +function resolveDependencies(sources) { + const knownTypes = new Map(sources.map(source => [source.fqcn, source])); + const typesByPackage = new Map(); + for (const source of sources) { + if (!typesByPackage.has(source.packageName)) typesByPackage.set(source.packageName, []); + typesByPackage.get(source.packageName).push(source); + } + const dependencies = []; + const seen = new Set(); + for (const source of sources) { + const candidates = new Set(); + for (const imported of source.imports) { + if (knownTypes.has(imported)) candidates.add(imported); + else if (imported.startsWith(internalPrefix)) { + const owner = [...knownTypes.keys()].find(type => imported.startsWith(`${type}.`)); + if (owner) candidates.add(owner); + } + } + for (const samePackageType of typesByPackage.get(source.packageName) ?? []) { + if (samePackageType.fqcn !== source.fqcn && new RegExp(`\\b${samePackageType.typeName}\\b`).test(source.source)) { + candidates.add(samePackageType.fqcn); + } + } + for (const targetFqcn of candidates) { + if (targetFqcn === source.fqcn) continue; + const key = `${source.fqcn}->${targetFqcn}`; + if (seen.has(key)) continue; + seen.add(key); + dependencies.push({ source: source.fqcn, target: targetFqcn }); + } + } + return dependencies.sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function aggregateEdges(dependencies, nodeOf) { + const counts = new Map(); + for (const dependency of dependencies) { + const source = nodeOf(dependency.source); + const target = nodeOf(dependency.target); + if (source === target) continue; + const key = `${source}\0${target}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [source, target] = key.split('\0'); + return { source, target, count }; + }).sort((a, b) => `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`)); +} + +function stronglyConnectedComponents(nodes, edges) { + const adjacency = new Map(nodes.map(node => [node, []])); + for (const edge of edges) adjacency.get(edge.source)?.push(edge.target); + const indexByNode = new Map(); + const lowLink = new Map(); + const stack = []; + const onStack = new Set(); + const components = []; + let nextIndex = 0; + function visit(node) { + indexByNode.set(node, nextIndex); + lowLink.set(node, nextIndex); + nextIndex += 1; + stack.push(node); + onStack.add(node); + for (const target of adjacency.get(node) ?? []) { + if (!indexByNode.has(target)) { + visit(target); + lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(target))); + } else if (onStack.has(target)) { + lowLink.set(node, Math.min(lowLink.get(node), indexByNode.get(target))); + } + } + if (lowLink.get(node) === indexByNode.get(node)) { + const component = []; + let current; + do { + current = stack.pop(); + onStack.delete(current); + component.push(current); + } while (current !== node); + components.push(component.sort()); + } + } + for (const node of [...nodes].sort()) if (!indexByNode.has(node)) visit(node); + return components.filter(component => component.length > 1).sort((a, b) => b.length - a.length || a[0].localeCompare(b[0])); +} + +function buildReport(sourceSet) { + const sources = collectSources(sourceSet); + const sourceByFqcn = new Map(sources.map(source => [source.fqcn, source])); + const dependencies = resolveDependencies(sources); + const packages = [...new Set(sources.map(source => source.packageName))].sort(); + const slices = [...new Set(sources.map(source => source.slice))].sort(); + const packageEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).packageName); + const sliceEdges = aggregateEdges(dependencies, fqcn => sourceByFqcn.get(fqcn).slice); + const directCrossSliceDependencies = dependencies.filter(edge => sourceByFqcn.get(edge.source).slice !== sourceByFqcn.get(edge.target).slice); + const modelLeaks = directCrossSliceDependencies.filter(edge => sourceByFqcn.get(edge.target).kind === 'data-model'); + const mapperLeaks = directCrossSliceDependencies.filter(edge => /\.mapper(?:\.|$)/.test(sourceByFqcn.get(edge.target).packageName)); + return { + schemaVersion: 1, + generatedFrom: sourceSet, + moduleRoot: 'lastleg-service', + summary: { + javaFiles: sources.length, + internalClassDependencies: dependencies.length, + packages: packages.length, + slices: slices.length, + crossSliceClassDependencies: directCrossSliceDependencies.length, + crossSliceModelLeaks: modelLeaks.length, + crossSliceMapperLeaks: mapperLeaks.length, + }, + sliceDefinitions: Object.fromEntries(sliceRules.map(([name, expression]) => [name, expression.source]).concat([['shared-technical', 'fallback']])), + classes: sources.map(({ source, ...entry }) => entry), + classDependencies: dependencies, + packageEdges, + sliceEdges, + packageSccs: stronglyConnectedComponents(packages, packageEdges), + sliceSccs: stronglyConnectedComponents(slices, sliceEdges), + directCrossSliceDependencies, + modelLeaks, + mapperLeaks, + }; +} + +function renderMarkdown(report) { + const leaks = [...report.modelLeaks, ...report.mapperLeaks] + .filter((edge, index, entries) => entries.findIndex(candidate => candidate.source === edge.source && candidate.target === edge.target) === index); + return [ + '# Lastleg internal dependency report', '', + `- Source set: ${report.generatedFrom}`, + `- Java files: ${report.summary.javaFiles}`, + `- Internal class dependencies: ${report.summary.internalClassDependencies}`, + `- Packages: ${report.summary.packages}`, + `- Cross-slice class dependencies: ${report.summary.crossSliceClassDependencies}`, + `- Cross-slice model leaks: ${report.summary.crossSliceModelLeaks}`, + `- Cross-slice mapper leaks: ${report.summary.crossSliceMapperLeaks}`, + `- Package SCCs (>1 node): ${report.packageSccs.length}`, + `- Slice SCCs (>1 node): ${report.sliceSccs.length}`, + '', '## Slice edges', '', '| From | To | Class edges |', '|---|---|---:|', + ...report.sliceEdges.map(edge => `| ${edge.source} | ${edge.target} | ${edge.count} |`), + '', '## Slice SCCs', '', + ...(report.sliceSccs.length ? report.sliceSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Package SCCs', '', + ...(report.packageSccs.length ? report.packageSccs.map(component => `- ${component.join(' -> ')}`) : ['- None']), + '', '## Cross-slice model and mapper leaks', '', + ...(leaks.length ? leaks.map(edge => `- \`${edge.source}\` -> \`${edge.target}\``) : ['- None']), + '', + ].join('\n'); +} + +function renderDot(report) { + const lines = ['digraph lastleg_slices {', ' rankdir=LR;']; + for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); + lines.push('}', ''); + return lines.join('\n'); +} + +function checkContract(report, contractPath) { + const absolute = path.resolve(process.cwd(), contractPath); + const contract = JSON.parse(fs.readFileSync(absolute, 'utf8')); + const allowed = new Set(contract.allowedSliceEdges ?? []); + const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); + const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { + const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); + const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); + if (JSON.stringify(outgoing) !== JSON.stringify([...(rule.allowedOutgoing ?? [])].sort())) { + violations.push(`${slice} outgoing dependencies changed: ${outgoing.join(', ')}`); + } + if (JSON.stringify(incoming) !== JSON.stringify([...(rule.allowedIncoming ?? [])].sort())) { + violations.push(`${slice} incoming dependencies changed: ${incoming.join(', ')}`); + } + } + if (violations.length > 0) { + process.stderr.write(`Lastleg dependency contract violations in ${contractPath}:\n${violations.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + } +} + +const options = parseArgs(process.argv.slice(2)); +const report = buildReport(options.sourceSet); +if (options.check) checkContract(report, options.check); +const rendered = options.format === 'json' + ? `${JSON.stringify(report, null, 2)}\n` + : options.format === 'markdown' + ? renderMarkdown(report) + : renderDot(report); +if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); +else process.stdout.write(rendered); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java new file mode 100644 index 000000000..27a8b5cf0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.config; + +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; + +@Component +public class LastlegRestClientFactory { + public RestClient build(RestClient.Builder builder, String baseUrl, + LastlegRestClientProperties.ClientTimeouts timeouts) { + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java new file mode 100644 index 000000000..be6fd6900 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@ConfigurationProperties(prefix = "baiyue.http.clients") +public class LastlegRestClientProperties { + private ClientTimeouts unloadPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts relabelPhotoFile = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(30)); + private ClientTimeouts warehouseResource = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + private ClientTimeouts palletPhysical = new ClientTimeouts(Duration.ofSeconds(3), Duration.ofSeconds(5)); + + public ClientTimeouts getUnloadPhotoFile() { return unloadPhotoFile; } + public void setUnloadPhotoFile(ClientTimeouts value) { unloadPhotoFile = required(value, "unload-photo-file"); } + public ClientTimeouts getRelabelPhotoFile() { return relabelPhotoFile; } + public void setRelabelPhotoFile(ClientTimeouts value) { relabelPhotoFile = required(value, "relabel-photo-file"); } + public ClientTimeouts getWarehouseResource() { return warehouseResource; } + public void setWarehouseResource(ClientTimeouts value) { warehouseResource = required(value, "warehouse-resource"); } + public ClientTimeouts getPalletPhysical() { return palletPhysical; } + public void setPalletPhysical(ClientTimeouts value) { palletPhysical = required(value, "pallet-physical"); } + + private static ClientTimeouts required(ClientTimeouts value, String client) { + if (value == null) throw new IllegalArgumentException(client + " HTTP timeouts are required"); + return value; + } + + public static class ClientTimeouts { + private Duration connectTimeout; + private Duration readTimeout; + + public ClientTimeouts() { } + + public ClientTimeouts(Duration connectTimeout, Duration readTimeout) { + setConnectTimeout(connectTimeout); + setReadTimeout(readTimeout); + } + + public Duration getConnectTimeout() { return connectTimeout; } + public void setConnectTimeout(Duration value) { connectTimeout = positive(value, "connect-timeout"); } + public Duration getReadTimeout() { return readTimeout; } + public void setReadTimeout(Duration value) { readTimeout = positive(value, "read-timeout"); } + + private static Duration positive(Duration value, String name) { + if (value == null || value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java index 3195adf30..6a8b0c243 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -19,12 +21,17 @@ import java.io.IOException; public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO"; @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; @PostConstruct - void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getUnloadPhotoFile()); + } @Override public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { 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 5bdfcc3f3..ea792ea46 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 @@ -1,5 +1,7 @@ package com.baiyue.lastleg.inboundplan.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import com.fasterxml.jackson.annotation.JsonAlias; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatusCode; @@ -15,6 +17,8 @@ import java.util.Map; @Component public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; @@ -26,7 +30,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { @PostConstruct void initializeClient() { - this.client = restClientBuilder.baseUrl(baseUrl).build(); + this.client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getWarehouseResource()); } @Override diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index c93045fd4..4b4154ddf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -13,11 +15,16 @@ import java.util.Map; @Component public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.warehouse.base-url:http://127.0.0.1:9305}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; - @PostConstruct void initialize() { client = restClientBuilder.baseUrl(baseUrl).build(); } + @PostConstruct void initialize() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getPalletPhysical()); + } @Override public PalletReference resolve(String palletCode) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java index 12cf36e23..d693674a3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.relabel.pda.gateway; +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; @@ -19,12 +21,17 @@ import java.io.IOException; public class HttpRelabelPhotoFileGateway implements RelabelPhotoFileGateway { private static final String BUSINESS_TYPE = "OVERSEAS_RELABEL_PHOTO"; @Resource private RestClient.Builder restClientBuilder; + @Resource private LastlegRestClientFactory restClientFactory; + @Resource private LastlegRestClientProperties restClientProperties; @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; @Value("${baiyue.security.internal-service-token:}") private String internalToken; private RestClient client; @PostConstruct - void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + void initializeClient() { + client = restClientFactory.build(restClientBuilder, baseUrl, + restClientProperties.getRelabelPhotoFile()); + } @Override public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 1aa3d7f34..3699c27ba 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -41,6 +41,20 @@ lastleg: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} baiyue: + http: + clients: + unload-photo-file: + connect-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_UNLOAD_PHOTO_FILE_READ_TIMEOUT:30s} + relabel-photo-file: + connect-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_RELABEL_PHOTO_FILE_READ_TIMEOUT:30s} + warehouse-resource: + connect-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_WAREHOUSE_RESOURCE_READ_TIMEOUT:5s} + pallet-physical: + connect-timeout: ${LASTLEG_PALLET_PHYSICAL_CONNECT_TIMEOUT:3s} + read-timeout: ${LASTLEG_PALLET_PHYSICAL_READ_TIMEOUT:5s} clients: domestic-warehouse: url: ${DOMESTIC_WAREHOUSE_URL:} From 4cca194eee6c8a1b42b7d4ea8748c3aaea7072fa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 12:30:08 +0800 Subject: [PATCH 164/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/scripts/analyze-lastleg-dependencies.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index 378e380e8..03146b819 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,10 +12,10 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ - ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], - ['inventory', /(?:^|\.)inventory(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], + ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], + ['inventory', /(?:^|\.)inventory(?:\.|$)/], ['dropship', /(?:^|\.)dropship(?:\.|$)/], ['procurement-trucking-looseboard', /(?:^|\.)(?:procurement|trucking|looseboard)(?:\.|$)/], ['overseas-master-data', /(?:^|\.)masterdata(?:\.|$)/], From d686b05966b1df7b9a4579b8942718e4a1fd58ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:02:26 +0800 Subject: [PATCH 165/231] fix(firstleg): expose event bridge relay failures --- .../service/LoadingCompletedOutboxRelay.java | 34 +++++++++++++-- firstleg/src/main/resources/application.yml | 2 +- ...entBridgeClientDeploymentContractTest.java | 32 +++++++++++++++ .../LoadingCompletedOutboxRelayTest.java | 41 ++++++++++++++++++- 4 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index c635fbe76..f01597951 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -1,5 +1,6 @@ package com.baiyue.firstleg.service; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.firstleg.client.EventBridgeClient; @@ -38,8 +39,7 @@ public class LoadingCompletedOutboxRelay { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); var response=client.receive(internalToken,event); - if(response==null || !response.successful() || response.data()==null - || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认头程跨区事件"); + requireAcknowledgement(event, response); repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); @@ -50,11 +50,37 @@ public class LoadingCompletedOutboxRelay { } } + private void requireAcknowledgement(EventEnvelope event, + ApiResponse response) { + if(response==null) throw new IllegalStateException("事件桥未返回响应"); + if(!response.successful()) { + throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + + ", message=" + response.message() + ", requestId=" + response.requestId() + + ", traceId=" + response.traceId()); + } + if(response.data()==null || !event.eventId().equals(response.data().eventId())) { + throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() + + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); + } + } + private String safeError(Exception exception) { - String value=exception.getMessage(); - if(value==null || value.isBlank()) value=exception.getClass().getSimpleName(); + Throwable diagnostic=diagnosticCause(exception); + String message=diagnostic.getMessage(); + String value=diagnostic.getClass().getSimpleName() + + ((message==null || message.isBlank())?"":": " + message); if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***"); value=value.replaceAll("[\\r\\n\\t]+"," "); return value.length()>500?value.substring(0,500):value; } + + private Throwable diagnosticCause(Throwable failure) { + Throwable current=failure; + while(current.getCause()!=null && current.getCause()!=current + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) { + current=current.getCause(); + } + return current; + } } diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index ed6994938..1a5342f3a 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -44,7 +44,7 @@ baiyue: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312} + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java new file mode 100644 index 000000000..b22423e5f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/EventBridgeClientDeploymentContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EventBridgeClientDeploymentContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml")); + + assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}")); + assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1")); + assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}")); + } + + @Test + void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception { + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}")); + assertTrue(compose.contains("SERVER_PORT: 9312")); + assertTrue(compose.contains("aliases: [event-bridge-cn]")); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index abd8ea172..3970763f6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -18,6 +19,7 @@ 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.cloud.client.circuitbreaker.NoFallbackAvailableException; import java.time.Instant; import java.time.LocalDateTime; @@ -73,7 +75,44 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote")); + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: failed *** remote")); + } + + @Test + void persistsRootCauseWhenCircuitBreakerWrapsBridgeFailure() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow( + new NoFallbackAvailableException("No fallback available.", + new IllegalStateException("Connection refused: event-bridge-cn:9312"))); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), + eq("IllegalStateException: Connection refused: event-bridge-cn:9312")); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void persistsDownstreamCodeAndRequestEvidenceWhenBridgeRejectsEvent() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure( + ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456")); + + relay.relay(); + + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( + "IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, " + + "requestId=request-123, traceId=trace-456")); + verify(repository, never()).published(anyLong(), anyString(), any()); } private InboundOutboxEntity row(EventEnvelope event) throws Exception { From 995c1833f8ba46fea9dc01a25a9e1c2c01eaa310 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:06:09 +0800 Subject: [PATCH 166/231] test: align phase3 and overseas business acceptance --- scripts/admin-test-env-acceptance.mjs | 4 +- .../inbound-measurement.mjs | 87 +++++++++++++++++-- .../phase3-http-fail-closed-contract.test.mjs | 19 ++-- .../phase3-outbound-dispatch.mjs | 60 +++++-------- .../phase3-producer-chain-contract.test.mjs | 7 ++ .../phase3-waiting-for-loading.mjs | 14 +-- ...phase4-overseas-business-contract.test.mjs | 87 ++++++++++++++++--- .../phase4-overseas-business.mjs | 75 +++++++++++++--- 8 files changed, 269 insertions(+), 84 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ea82e9925..91fa0303d 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -903,7 +903,8 @@ export class AcceptanceRun { const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', - 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount'] + 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount', + 'phase3LoadingIncrementalDetailCount'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) @@ -947,6 +948,7 @@ export class AcceptanceRun { this.marker = incrementalMarker this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingIncrementalPackageCount = 2 + this.context.phase3LoadingIncrementalDetailCount = 4 await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index e3707c25e..6c2f7e14d 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -339,9 +339,9 @@ export async function run(context) { && closeTo(rescan?.lengthCm, replacement.length), `sameItem=${asId(rescan?.itemId) === asId(firstScan.itemId)}, confirmedPackageCount=${rescan?.confirmedPackageCount ?? 'missing'}`, OPTIONS) - const measuredResponse = await context.request('GET', measurementPath) - const measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) - const measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) + let measuredResponse = await context.request('GET', measurementPath) + let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) + let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) // The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge. const weightedOuter = { length: 64, width: 54, height: 54 } const unitVolume = halfUp( @@ -362,6 +362,63 @@ export async function run(context) { && closeTo(measured.actualVolumeCbm, totalVolume) && closeTo(measured.volumeWeightKg, volumeWeight), `weightedOuter=${weightedOuter.length}x${weightedOuter.width}x${weightedOuter.height}, unitVolume=${unitVolume}, totalVolume=${totalVolume}, volumeWeight=${volumeWeight}, girth=${girth}`, OPTIONS) + const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1) + if (incrementalDetailCount > 1) { + const packagesPerDetail = Number(context.context?.phase3LoadingIncrementalPackageCount || 1) + const expectedPackages = incrementalDetailCount * packagesPerDetail + context.assert(MODULE, '动态增量多明细箱数前置与冻结总数一致', measuredResponse, + expectedPackages === originalPackageCount, + `detailCount=${incrementalDetailCount}, packagesPerDetail=${packagesPerDetail}, frozen=${originalPackageCount}`, OPTIONS) + if (expectedPackages === originalPackageCount) { + const resetFirstCountResponse = await context.request('PUT', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, + { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) + const resetFirstCount = context.apiSuccess(MODULE, + '增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse, + `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, + Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail + && Number(resetFirstCount?.measurementVersion) === Number(measured?.version) + 1, + `version=${resetFirstCount?.measurementVersion ?? 'missing'}`, OPTIONS) + + for (let detailIndex = 1; detailIndex < incrementalDetailCount; detailIndex += 1) { + const cartonIndex = detailIndex * packagesPerDetail + 1 + const detailBoxCode = `${inboundCode}#${originalPackageCount}-${cartonIndex}` + const scanResponse = await multipartRequest(context, scanPath, { + boxCode: detailBoxCode, ...replacement, + scannedShipmentId: `FBA-${context.marker.slice(-12)}-${String(detailIndex + 2).padStart(2, '0')}`, + machineNo: `MJ-ADMIN-E2E-INC-${detailIndex + 1}`, + measuredAt: new Date().toISOString().slice(0, 19), + }) + const scanned = context.apiSuccess(MODULE, + `增量 fixture 扫描第${detailIndex + 1}条独立货物箱码`, scanResponse, + `boxCode=${detailBoxCode}`, OPTIONS) + context.assert(MODULE, `增量第${detailIndex + 1}条扫描解析独立货物`, scanResponse, + scanned?.created === true && asId(scanned?.itemId) + && asId(scanned?.itemId) !== asId(firstScan.itemId), + `itemId=${asId(scanned?.itemId) || 'missing'}, inboundDetailId=${asId(scanned?.inboundDetailId) || 'pending-query'}`, OPTIONS) + if (!scanned?.itemId) continue + const countDetailResponse = await context.request('PUT', + `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${scanned.itemId}/confirmed-package-count?${query(tenantId)}`, + { measurementVersion: Number(scanned.measurementVersion), confirmedPackageCount: packagesPerDetail }) + const countDetail = context.apiSuccess(MODULE, + `增量第${detailIndex + 1}条货物确认自身箱数`, countDetailResponse, + `confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + context.assert(MODULE, `增量第${detailIndex + 1}条货物箱数更新正确`, countDetailResponse, + Number(countDetail?.confirmedPackageCount) === packagesPerDetail, + `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) + } + measuredResponse = await context.request('GET', measurementPath) + measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS) + measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) + const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean)) + context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse, + detailIds.size >= incrementalDetailCount + && measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount, + `measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS) + } + } + let inboundDetailId = measuredItem?.inboundDetailId if (!inboundDetailId) { const orderId = asId(frozenSummary?.orderId || context.createdResources?.orders?.orderId) @@ -387,10 +444,18 @@ export async function run(context) { return } - const draftPayload = { - measurementId: measured.measurementId, version: Number(measured.version), - actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, - items: [{ + const draftItems = incrementalDetailCount > 1 + ? (measured?.items || []).filter((item) => asId(item?.inboundDetailId) && asId(item?.intelligentImageFileId)) + .slice(0, incrementalDetailCount).map((item) => ({ + inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, + boxSpecification: item.boxSpecification, packageCount: Number(item.confirmedPackageCount), + lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm), + intelligentImageFileId: item.intelligentImageFileId, + overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: item.outerLengthCm ?? null, outerWidthCm: item.outerWidthCm ?? null, + outerHeightCm: item.outerHeightCm ?? null, remark: context.marker, + })) + : [{ inboundDetailId, itemNo: measuredItem.itemNo, boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount, lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height, @@ -398,12 +463,16 @@ export async function run(context) { overLimitWarning: measuredItem.overLimitWarning ?? null, outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null, outerHeightCm: measuredItem.outerHeightCm ?? null, remark: context.marker, - }], + }] + const draftPayload = { + measurementId: measured.measurementId, version: Number(measured.version), + actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`, + items: draftItems, } const draftResponse = await context.request('PUT', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/draft?${query(tenantId)}`, draftPayload) const draft = context.apiSuccess(MODULE, '保存同一测量批次草稿', draftResponse, - `measurementId=${asId(measured.measurementId)}, items=1`, OPTIONS) + `measurementId=${asId(measured.measurementId)}, items=${draftItems.length}`, OPTIONS) context.assert(MODULE, '草稿沿用批次并递增版本', draftResponse, asId(draft?.measurementId) === asId(measured.measurementId) && Number(draft?.version) === Number(measured.version) + 1 && draft?.status === 'DRAFT', diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index fd73c84af..36331259e 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -37,17 +37,26 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/) }) -test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { +test('outbound lifecycle checks confirm replay, immutable departure reverse boundary and final reread', () => { const source = read('phase3-outbound-dispatch.mjs') for (const step of [ 'confirm-dispatch 幂等重放', '相同 Idempotency-Key 不同请求拒绝', - 'reverse-dispatch 幂等重放', - 'reverse-dispatch 同键异参返回 A0409', - '撤销后旧版本再次确认返回 A0409', + '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', + '禁止撤销边界重复请求仍精确返回A0409', '负向与幂等验证后回查出仓聚合', - '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', + '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', ]) assert.match(source, new RegExp(step)) assert.match(source, /if \(!confirmed\?\.aggregate\) return/) assert.match(source, /hashConflict\.http === 200 && codeOf\(hashConflict\) === 'A0409'/) + const confirm = source.indexOf('确认国内出仓进入等待装船') + const reverse = source.indexOf('实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409') + const finalRead = source.indexOf('负向与幂等验证后回查出仓聚合') + assert.ok(confirm >= 0 && reverse > confirm && finalRead > reverse) + assert.match(source, /confirmedStatus: confirmed\.aggregate\.dispatchStatus/) + assert.doesNotMatch(source, /outbound-confirm-final|finalConfirmed|reversedVersion/) + const waiting = read('phase3-waiting-for-loading.mjs') + assert.match(waiting, /confirmations\.length === 1 && reversed\.length === 0/) + assert.match(waiting, /实际离仓节点冻结后保持一次确认且零次成功撤销/) + assert.match(waiting, /detail\?\.aggregate\?\.dispatchStatus === 'WAITING_FOR_LOADING'/) }) diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 94611f91e..a489ba65c 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -127,52 +127,40 @@ export async function run(context) { const reverseResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) - const reversed = context.apiSuccess(MODULE, '撤销国内出仓恢复装柜完成', reverseResponse, '', OPTIONS) - context.assert(MODULE, 'reverse-dispatch 恢复 READY_FOR_DISPATCH 与 LOADED', reverseResponse, - reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' - && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), - `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) + context.addStep(MODULE, '实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409', reverseResponse, + reverseResponse.http === 200 && codeOf(reverseResponse) === 'A0409' + && String(reverseResponse.body?.message || '').includes('DEPARTED_WAREHOUSE') + && String(reverseResponse.body?.message || '').includes('禁止撤销'), + `HTTP=${reverseResponse.http}, code=${codeOf(reverseResponse)}, message=${reverseResponse.body?.message || 'missing'}`, OPTIONS) const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, }, { 'Idempotency-Key': reverseKey }) - const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS) - context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse, - reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' - && Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version), - `dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS) - - const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, { - inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged', - }, { 'Idempotency-Key': reverseKey }) - context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict, - reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409', - `HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS) - - const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, - { 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) }) - context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse, - staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409', - `HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS) + context.addStep(MODULE, '禁止撤销边界重复请求仍精确返回A0409', reverseReplayResponse, + reverseReplayResponse.http === 200 && codeOf(reverseReplayResponse) === 'A0409' + && String(reverseReplayResponse.body?.message || '').includes('DEPARTED_WAREHOUSE'), + `HTTP=${reverseReplayResponse.http}, code=${codeOf(reverseReplayResponse)}`, OPTIONS) const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) - context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse, - finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse, + finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && finalDetail?.aggregate?.inboundStatus === 'LOADED' - && Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version) - && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH') - && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), + && Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version) + && finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH' + && item.action === 'CONFIRM_DISPATCH').length === 1 + && !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' + && item.action === 'REVERSE_DISPATCH'), `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) - if (reversed?.aggregate) { + if (confirmed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, - orderId: id(reversed.aggregate.orderId), tenantId: id(reversed.aggregate.tenantId), - confirmedStatus: confirmed?.aggregate?.dispatchStatus || null, - confirmedInboundStatus: confirmed?.aggregate?.inboundStatus || null, - confirmedVersion: Number(confirmed?.aggregate?.version), - finalStatus: reversed.aggregate.dispatchStatus, - finalInboundStatus: reversed.aggregate.inboundStatus, - finalVersion: Number(reversed.aggregate.version), + orderId: id(confirmed.aggregate.orderId), tenantId: id(confirmed.aggregate.tenantId), + confirmedStatus: confirmed.aggregate.dispatchStatus, + confirmedInboundStatus: confirmed.aggregate.inboundStatus, + confirmedVersion: Number(confirmed.aggregate.version), + finalStatus: confirmed.aggregate.dispatchStatus, + finalInboundStatus: confirmed.aggregate.inboundStatus, + finalVersion: Number(confirmed.aggregate.version), } } } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6150e4891..8a5da135f 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -460,6 +460,8 @@ test('phase3 loading proves stale inventory zero-write and defers final completi test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstream completion', () => { const loading = source('./phase3-loading.mjs') + const entry = source('../admin-test-env-acceptance.mjs') + const measurement = source('./inbound-measurement.mjs') assertOrder(loading, [ '本轮增量 fixture 提供独立 SKIP 与 PARTIAL 合法货物明细', '加入本轮待跳拣异常明细', @@ -486,6 +488,11 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(loading, /exceptionOverview\?\.pda\?\.exceptionReasons\?\.includes\?\.\(skipReason\)/) assert.match(loading, /Number\(exceptionOverview\?\.pda\?\.unresolvedItemCount\) === 0/) assert.doesNotMatch(loading, /addCoverageGap\([^)]*(SKIP|PARTIAL|未解决异常)/) + assert.match(entry, /this\.context\.phase3LoadingIncrementalDetailCount = 4/) + assert.match(measurement, /增量 fixture 形成四条真实独立货物测量明细/) + assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) + assert.match(measurement, /items: draftItems/) + assert.match(measurement, /expectedPackages === originalPackageCount/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 21789ecc5..6729d98f0 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -17,17 +17,17 @@ export async function run(context) { const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS) const history = Array.isArray(detail?.timeline) ? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : [] - const confirmed = history.find((item) => item.action === 'CONFIRM_DISPATCH') - const reversed = history.find((item) => item.action === 'REVERSE_DISPATCH') + const confirmations = history.filter((item) => item.action === 'CONFIRM_DISPATCH') + const confirmed = confirmations[0] + const reversed = history.filter((item) => item.action === 'REVERSE_DISPATCH') context.assert(MODULE, '确认出仓真实进入 WAITING_FOR_LOADING', response, fixture.confirmedStatus === 'WAITING_FOR_LOADING' && fixture.confirmedInboundStatus === 'LOADED' && confirmed?.fromStatus === 'READY_FOR_DISPATCH' && confirmed?.toStatus === 'WAITING_FOR_LOADING' && Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt), `confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS) - context.assert(MODULE, '撤销后状态恢复且 WAITING 历史不可变', response, - detail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && detail?.aggregate?.inboundStatus === 'LOADED' - && reversed?.fromStatus === 'WAITING_FOR_LOADING' && reversed?.toStatus === 'READY_FOR_DISPATCH' - && Boolean(reversed?.operatorId) && Boolean(reversed?.occurredAt), - `current=${detail?.aggregate?.dispatchStatus || 'missing'}, reversed=${reversed?.fromStatus || 'missing'}->${reversed?.toStatus || 'missing'}`, OPTIONS) + context.assert(MODULE, '实际离仓节点冻结后保持一次确认且零次成功撤销', response, + detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && detail?.aggregate?.inboundStatus === 'LOADED' + && confirmations.length === 1 && reversed.length === 0, + `current=${detail?.aggregate?.dispatchStatus || 'missing'}, confirmations=${confirmations.length}, reversals=${reversed.length}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index f7bee94cc..82436c24a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -20,6 +20,7 @@ function fixture(options = {}) { const state = { warehouseStatus: 'ACTIVE', purchase: null, purchaseHash: '', truck: null, truckHash: '', looseboard: null, looseboardHash: '', purchaseHistories: [], truckHistories: [], looseboardHistories: [], + purchaseReviews: new Map(), truckReviews: new Map(), truckStatuses: new Map(), looseboardStatuses: new Map(), } const activeCustomers = options.activeCustomers ?? [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }] const warehouseOptions = options.warehouseOptions ?? [ @@ -71,11 +72,19 @@ function fixture(options = {}) { if (requestPath.includes('/warehouse-options')) return response(warehouseOptions) if (requestPath.includes('/overseas-procurements?') && method === 'GET') return response(page(state.purchase ? [state.purchase] : [])) if (requestPath.includes('/overseas-procurements/OP1/review?') && method === 'POST') { + assert.equal(Number.isInteger(Number(body.version)), true) + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.purchaseReviews.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (state.purchase.status !== 'PENDING_REVIEW') return response(null, 'A0409') + if (Number(body.version) !== state.purchase.version) return response(null, 'A0409') const next = body.decision === 'REJECT' ? 'REJECTED' : 'APPROVED' state.purchase = { ...state.purchase, status: next, version: state.purchase.version + 1 } state.purchaseHistories.push({ operationType: 'REVIEW', fromStatus: 'PENDING_REVIEW', toStatus: next, remark: body.remark }) - return response(purchaseDetail()) + const result = structuredClone(purchaseDetail()) + state.purchaseReviews.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/overseas-procurements/OP1?') && method === 'GET') return response(purchaseDetail()) if (requestPath.includes('/overseas-procurements/OP1?') && method === 'PATCH') { @@ -102,15 +111,37 @@ function fixture(options = {}) { return response(truckDetail()) } if (requestPath.includes('/us-truck-orders?') && method === 'GET') return response(page(state.truck ? [state.truck] : [])) - if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { - if (Number(body.version) !== state.truck.version) return response(null, 'A0409') - const allowed = (state.truck.status === 'PENDING_REVIEW' && ['WAITING_INBOUND', 'CANCELLED'].includes(body.status)) - || (state.truck.status === 'WAITING_INBOUND' && body.status === 'CANCELLED') - if (!allowed) return response(null, 'A0409') + if (requestPath.includes('/us-truck-orders/TK1/review?') && method === 'POST') { + assert.equal(Number.isInteger(Number(body.version)), true) + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.truckReviews.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') + if (state.truck.status !== 'PENDING_REVIEW' || Number(body.version) !== state.truck.version) { + return response(null, 'A0409') + } + const next = body.decision === 'APPROVE' ? 'WAITING_INBOUND' : 'REJECTED' const from = state.truck.status - state.truck = { ...state.truck, status: body.status, version: state.truck.version + 1 } - state.truckHistories.push({ fromStatus: from, toStatus: body.status, remark: body.remark }) - return response(truckDetail()) + state.truck = { ...state.truck, status: next, version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: from, toStatus: next, remark: body.remark }) + const result = structuredClone(truckDetail()) + state.truckReviews.set(body.idempotencyKey, { hash, result }) + return response(result) + } + if (requestPath.includes('/us-truck-orders/TK1/status?') && method === 'PATCH') { + assert.equal(body.status, 'CANCELLED') + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.truckStatuses.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') + if (Number(body.version) !== state.truck.version) return response(null, 'A0409') + if (!['PENDING_REVIEW', 'WAITING_INBOUND'].includes(state.truck.status)) return response(null, 'A0409') + const from = state.truck.status + state.truck = { ...state.truck, status: 'CANCELLED', version: state.truck.version + 1 } + state.truckHistories.push({ fromStatus: from, toStatus: 'CANCELLED', remark: body.remark }) + const result = structuredClone(truckDetail()) + state.truckStatuses.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/us-truck-orders/TK1?') && method === 'GET') return response(truckDetail()) if (requestPath.includes('/us-truck-orders/TK1?') && method === 'PATCH') { @@ -130,6 +161,10 @@ function fixture(options = {}) { } if (requestPath.includes('/local-looseboard-orders?') && method === 'GET') return response(page(state.looseboard ? [state.looseboard] : [])) if (requestPath.includes('/local-looseboard-orders/LB1/status?') && method === 'PATCH') { + assert.equal(typeof body.idempotencyKey === 'string' && body.idempotencyKey.length > 0, true) + const hash = JSON.stringify(body) + const replay = state.looseboardStatuses.get(body.idempotencyKey) + if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status)) || (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED') @@ -137,7 +172,9 @@ function fixture(options = {}) { const from = state.looseboard.status state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 } state.looseboardHistories.push({ operationType: 'STATUS', fromStatus: from, toStatus: body.status, remark: body.remark }) - return response(looseboardDetail()) + const result = structuredClone(looseboardDetail()) + state.looseboardStatuses.set(body.idempotencyKey, { hash, result }) + return response(result) } if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail()) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { @@ -189,20 +226,41 @@ test('complete procurement truck and looseboard lifecycles pass with legal marke const procurementWrites = context.calls.filter((call) => call.path.includes('/overseas-procurements') && call.method !== 'GET') assert.deepEqual(procurementWrites.map((call) => call.method), - ['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST']) + ['POST', 'POST', 'POST', 'PATCH', 'PATCH', 'PATCH', 'PATCH', 'POST', 'POST', 'POST', 'POST']) assert.equal(procurementWrites.filter((call) => call.method === 'PATCH').length, 4) assert.ok(procurementWrites.some((call) => call.method === 'PATCH' && call.body.productSpecification === null && call.body.productUrl === '')) + const procurementReviews = procurementWrites.filter((call) => call.path.includes('/review?')) + assert.equal(procurementReviews.length, 4) + assert.ok(procurementReviews.every((call) => Number.isInteger(Number(call.body.version)) + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(procurementReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.notEqual(procurementReviews[3].body.idempotencyKey, procurementReviews[0].body.idempotencyKey) const truckCalls = context.calls.filter((call) => call.path.includes('/us-truck-orders')) - assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 3) + assert.equal(truckCalls.filter((call) => call.method === 'POST').length, 7) assert.equal(truckCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) - assert.equal(truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + const truckReviews = truckCalls.filter((call) => call.method === 'POST' && call.path.includes('/review?')) + assert.equal(truckReviews.length, 4) + assert.ok(truckReviews.every((call) => Number.isInteger(Number(call.body.version)) + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(truckReviews.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.notEqual(truckReviews[3].body.idempotencyKey, truckReviews[0].body.idempotencyKey) + const truckStatuses = truckCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')) + assert.equal(truckStatuses.length, 1) + assert.ok(truckStatuses.every((call) => call.body.status === 'CANCELLED' + && typeof call.body.idempotencyKey === 'string' && call.body.idempotencyKey.length > 0)) const looseboardCalls = context.calls.filter((call) => call.path.includes('/local-looseboard-orders')) assert.equal(looseboardCalls.filter((call) => call.method === 'POST').length, 3) assert.equal(looseboardCalls.filter((call) => call.method === 'GET' && call.path.includes('page=1')).length, 1) assert.equal(looseboardCalls.filter((call) => call.method === 'PUT').length, 2) - assert.equal(looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')).length, 3) + const looseboardStatuses = looseboardCalls.filter((call) => call.method === 'PATCH' && call.path.includes('/status?')) + assert.equal(looseboardStatuses.length, 5) + assert.ok(looseboardStatuses.every((call) => typeof call.body.idempotencyKey === 'string' + && call.body.idempotencyKey.length > 0)) + assert.equal(new Set(looseboardStatuses.slice(0, 3).map((call) => call.body.idempotencyKey)).size, 1) + assert.equal(new Set(looseboardStatuses.slice(3).map((call) => call.body.idempotencyKey)).size, 2) + assert.ok(looseboardStatuses.slice(3).every((call) => call.body.idempotencyKey !== looseboardStatuses[0].body.idempotencyKey)) const registry = context.createdResources.phase4OverseasBusiness assert.equal(registry.purchase.finalStatus, 'REJECTED') @@ -265,5 +323,6 @@ test('contract locks candidates three-state idempotency histories legal terminal '/api/merchant/v1/last-leg/overseas-procurements', '显式 null 与空串清空', '字段缺失不修改', '同幂等键同载荷精确重放', '同幂等键异载荷返回冲突', '旧 version 返回 A0409', '状态正向推进', '状态逆向回退返回冲突', '流水覆盖', "status: 'CANCELLED'", + '/review', 'truck-review-approve', 'looseboard-status-dispatched', 'requestId=', 'traceId=', 'createdResources.phase4OverseasBusiness']) assert.ok(source.includes(token), token) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 5f6075550..0fc381806 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -248,14 +248,32 @@ async function procurementFlow(context, prerequisite) { const rejected = await overseas(context, 'POST', '驳回本轮采购订单并进入合法终态', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { decision: 'REJECT', remark: `${context.marker}-REJECTED` }) + { version: Number(afterConflictSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255), + remark: `${context.marker}-REJECTED` }) const rejectedSummary = summary(rejected.data) context.assert(MODULE, '采购审核正向驳回成功', rejected.response, rejectedSummary?.status === 'REJECTED' && Number(rejectedSummary?.version) === Number(clearedSummary?.version) + 1, `status=${rejectedSummary?.status || 'missing'}, version=${rejectedSummary?.version ?? 'missing'}; ${trace(rejected.response)}`, OPTIONS) + const rejectedReplayBody = { version: Number(afterConflictSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-reject`.slice(0, 255), + remark: `${context.marker}-REJECTED` } + const rejectedReplay = await overseas(context, 'POST', '采购审核同键同载荷精确重放', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + rejectedReplayBody) + context.assert(MODULE, '采购审核重放复用终态且版本不漂移', rejectedReplay.response, + summary(rejectedReplay.data)?.status === 'REJECTED' + && Number(summary(rejectedReplay.data)?.version) === Number(rejectedSummary?.version), + trace(rejectedReplay.response), OPTIONS) + const reviewHashConflict = await context.requestOverseas('POST', + query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), + { ...rejectedReplayBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '采购审核同键异载荷返回冲突', reviewHashConflict) const reverseReview = await context.requestOverseas('POST', query(`${BASE}/overseas-procurements/${encodeURIComponent(owned.orderNo)}/review`, { customerId }), - { decision: 'APPROVE', remark: 'REVERSE-DO-NOT-APPLY' }) + { version: Number(rejectedSummary?.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-procurement-review-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '采购审核终态逆向返回冲突', reverseReview) const final = await overseas(context, 'GET', '采购最终状态与流水回查', path) @@ -328,8 +346,11 @@ async function cancelMarkerOrder(context, resource, base, cancellableStatuses, l return } const statusPath = query(`${base}/${encodeURIComponent(resource.orderNo)}/status`, { customerId: resource.customerId }) + const keyScope = label === '卡车' ? 'truck' : 'looseboard' const cancelled = await overseas(context, 'PATCH', `取消 marker ${label}订单`, statusPath, - { version: Number(currentSummary.version), status: 'CANCELLED', remark: `${context.marker}-acceptance-cleanup` }) + { version: Number(currentSummary.version), status: 'CANCELLED', + idempotencyKey: `${context.marker}-${keyScope}-${resource.orderNo}-cancel`.slice(0, 255), + remark: `${context.marker}-acceptance-cleanup` }) const cancelledSummary = summary(cancelled.data) const verified = await overseas(context, 'GET', `回查 marker ${label}订单最终状态`, detailPath) const finalSummary = summary(verified.data) @@ -381,17 +402,29 @@ async function truckFlow(context, customerId) { afterStaleSummary?.destination === after?.destination && Number(afterStaleSummary?.version) === Number(after?.version), `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) - const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) - const forwarded = await overseas(context, 'PATCH', '卡车状态正向推进到待入仓', statusPath, - { version: Number(afterStaleSummary?.version), status: 'WAITING_INBOUND', remark: `${context.marker}-FORWARD` }) + const reviewPath = query(`${base}/${encodeURIComponent(orderNo)}/review`, { customerId }) + const reviewBody = { version: Number(afterStaleSummary?.version), decision: 'APPROVE', + idempotencyKey: `${context.marker}-truck-review-approve`.slice(0, 255), + remark: `${context.marker}-FORWARD` } + const forwarded = await overseas(context, 'POST', '卡车审核正向推进到待入仓', reviewPath, reviewBody) const forwardedSummary = summary(forwarded.data) context.assert(MODULE, '卡车状态正向推进成功', forwarded.response, forwardedSummary?.status === 'WAITING_INBOUND' && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) - const reversed = await context.requestOverseas('PATCH', statusPath, - { version: Number(forwardedSummary?.version), status: 'PENDING_REVIEW', remark: 'REVERSE-DO-NOT-APPLY' }) - expectedConflict(context, '卡车状态逆向回退返回冲突', reversed) + const reviewReplay = await overseas(context, 'POST', '卡车审核同键同载荷精确重放', reviewPath, reviewBody) + context.assert(MODULE, '卡车审核重放复用待入仓终态且版本不漂移', reviewReplay.response, + summary(reviewReplay.data)?.status === 'WAITING_INBOUND' + && Number(summary(reviewReplay.data)?.version) === Number(forwardedSummary?.version), + trace(reviewReplay.response), OPTIONS) + const reviewMismatch = await context.requestOverseas('POST', reviewPath, + { ...reviewBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '卡车审核同键异载荷返回冲突', reviewMismatch) + const reversed = await context.requestOverseas('POST', reviewPath, + { version: Number(forwardedSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-truck-review-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) + expectedConflict(context, '卡车审核终态逆向返回冲突', reversed) const history = await overseas(context, 'GET', '卡车状态冲突后流水回查', path) const historySummary = summary(history.data) const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] @@ -448,14 +481,30 @@ async function looseboardFlow(context, customerId) { const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath, - { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', remark: `${context.marker}-FORWARD` }) + { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), + remark: `${context.marker}-FORWARD` }) const forwardedSummary = summary(forwarded.data) context.assert(MODULE, '散板状态正向推进成功', forwarded.response, forwardedSummary?.status === 'DISPATCHED' && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) + const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), + remark: `${context.marker}-FORWARD` } + const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath, + forwardBody) + context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response, + summary(forwardReplay.data)?.status === 'DISPATCHED' + && Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version), + trace(forwardReplay.response), OPTIONS) + const forwardMismatch = await context.requestOverseas('PATCH', statusPath, + { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) + expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch) const reversed = await context.requestOverseas('PATCH', statusPath, - { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', remark: 'REVERSE-DO-NOT-APPLY' }) + { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', + idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255), + remark: 'REVERSE-DO-NOT-APPLY' }) expectedConflict(context, '散板状态逆向回退返回冲突', reversed) const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path) const historySummary = summary(history.data) @@ -501,7 +550,9 @@ async function finalizePendingPurchase(context, resource) { const rejected = await overseas(context, 'POST', '驳回未完成的本轮采购订单', query(`${BASE}/overseas-procurements/${encodeURIComponent(resource.orderNo)}/review`, { customerId: resource.customerId }), - { decision: 'REJECT', remark: `${context.marker}-prerequisite-cleanup` }) + { version: Number(currentSummary?.version), decision: 'REJECT', + idempotencyKey: `${context.marker}-procurement-review-cleanup`.slice(0, 255), + remark: `${context.marker}-prerequisite-cleanup` }) const rejectedSummary = summary(rejected.data) const verified = await overseas(context, 'GET', '回查未完成采购订单最终状态', path) const finalSummary = summary(verified.data) From 79eb3efe61df537c97830f46c1b9b245f5919cef Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:07:01 +0800 Subject: [PATCH 167/231] fix(lastleg): align overseas business incremental schema --- deploy/sql/海外区域统一增量-MySQL8.sql | 62 +++++++++++++++++-- .../LocalLooseboardSchemaContractTest.java | 11 ++++ ...OverseasProcurementSchemaContractTest.java | 19 ++++++ .../UsTruckOrderSchemaContractTest.java | 12 ++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a64450395..afb0c1055 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -95,7 +95,8 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( 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 '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间', + 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 '更新时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no), @@ -104,15 +105,32 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order ( KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at) ) ENGINE=InnoDB COMMENT='海外采购订单'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL COMMENT ''审核请求内容哈希'' AFTER review_idempotency_key')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + 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='海外采购操作流水'; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key' +), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', @@ -146,11 +164,32 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( 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', - from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) 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), KEY idx_ll_us_truck_history(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='美国卡车订单状态历史'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='operation_type'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN operation_type VARCHAR(64) NOT NULL DEFAULT ''UPDATE'' COMMENT ''具名操作类型'' AFTER customer_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_us_truck_order_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +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_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', @@ -182,11 +221,26 @@ 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='本土散板操作流水'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_local_looseboard_history' AND column_name='idempotency_key'), 'SELECT 1', + 'ALTER TABLE ll_local_looseboard_history ADD COLUMN idempotency_key VARCHAR(255) NULL COMMENT ''操作幂等键'' AFTER operator_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +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='本土散板订单幂等操作账'; + -- 尾程主订单状态历史补齐客户隔离键。代码写入 customer_id,已有表需显式升级; -- 先允许 NULL 以便从主订单回填,再收紧为 NOT NULL,脚本可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() 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 b67cd7f22..90f0a93c5 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 @@ -2,6 +2,8 @@ package com.baiyue.lastleg.looseboard; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -24,4 +26,13 @@ class LocalLooseboardSchemaContractTest { assertTrue(sql.contains("customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键'")); assertTrue(sql.contains("idx_ll_loading_history_customer_order(customer_id,order_id,created_at)")); } + + @Test + void unifiedIncrementalContainsHistoryIdempotencyAndOperationLedger() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + 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("table_name='ll_local_looseboard_history' AND column_name='idempotency_key'")); + } } 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 a90369d8f..b6c1159a0 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 @@ -2,6 +2,8 @@ package com.baiyue.lastleg.procurement; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -23,4 +25,21 @@ class OverseasProcurementSchemaContractTest { 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")); } + + @Test + void unifiedIncrementalContainsHistoryIdempotencyKeyForNewAndExistingDatabases() throws Exception { + String sql = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertTrue(sql.contains("idempotency_key VARCHAR(255) NULL COMMENT '操作幂等键'")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_status_history ADD COLUMN idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL")); + assertTrue(sql.contains( + "ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_request_hash CHAR(64) NULL")); + assertTrue(sql.contains( + "table_name='ll_overseas_procurement_status_history' AND column_name='idempotency_key'")); + } } 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 8dd0a31d7..a3bc9d3d8 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 @@ -2,6 +2,8 @@ package com.baiyue.lastleg.trucking; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -26,4 +28,14 @@ class UsTruckOrderSchemaContractTest { 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)")); } + + @Test + void unifiedIncrementalContainsOperationLedgerAndHistoryColumns() throws Exception { + String sql = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); + assertTrue(sql.contains("uk_ll_us_truck_operation_key(customer_id,order_id,idempotency_key)")); + assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='operation_type'")); + assertTrue(sql.contains("table_name='ll_us_truck_order_status_history' AND column_name='idempotency_key'")); + } } From 2cfaf8e51b83ed075bd6a5a546f78c9d464c02b6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:23:05 +0800 Subject: [PATCH 168/231] fix(lastleg): isolate shipping node version stream --- .../com/baiyue/lastleg/service/LoadingCompletedConsumer.java | 5 ++++- .../baiyue/lastleg/service/LoadingCompletedConsumerTest.java | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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 5ef81e214..0896e5b77 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 @@ -73,8 +73,11 @@ public class LoadingCompletedConsumer { return countOrders(envelope,payload.containerNo()); } + // The loading-plan and container-shipping events are independent aggregate streams. + // Seeding the shipping version from the loading-plan version makes a valid first node + // (aggregateVersion=0) look stale whenever the loading plan has a higher version. shippingGates.initialize(payload.containerBookingId(), envelope.tenantId(), "", - payload.containerNo(), envelope.aggregateVersion(), envelope.handoverVersion()); + payload.containerNo(), 0, envelope.handoverVersion()); String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); JsonNode rawOrders = envelope.payload().path("orders"); 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 c54bb904c..23e55decb 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 @@ -5,7 +5,7 @@ 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; @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 loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,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 { From 6d8e4c85c50890fc7320dcd680b44e8efbfc4381 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:26:05 +0800 Subject: [PATCH 169/231] test(lastleg): align rest client gateway setup --- .../pda/gateway/HttpUnloadPhotoFileGatewayTest.java | 4 +--- .../pda/gateway/HttpWarehouseResourceGatewayTest.java | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java index 3149e00d5..734406198 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java @@ -23,10 +23,8 @@ class HttpUnloadPhotoFileGatewayTest { RestClient.Builder builder = RestClient.builder(); server = MockRestServiceServer.bindTo(builder).build(); gateway = new HttpUnloadPhotoFileGateway(); - ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); - ReflectionTestUtils.setField(gateway, "baseUrl", "http://file-service"); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://file-service").build()); ReflectionTestUtils.setField(gateway, "internalToken", "internal-token"); - gateway.initializeClient(); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index a4005d745..4b26ac3ca 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -20,10 +20,8 @@ class HttpWarehouseResourceGatewayTest { RestClient.Builder builder = RestClient.builder(); server = MockRestServiceServer.bindTo(builder).build(); gateway = new HttpWarehouseResourceGateway(); - ReflectionTestUtils.setField(gateway, "restClientBuilder", builder); - ReflectionTestUtils.setField(gateway, "baseUrl", "http://warehouse"); + ReflectionTestUtils.setField(gateway, "client", builder.baseUrl("http://warehouse").build()); ReflectionTestUtils.setField(gateway, "internalToken", "test-token"); - gateway.initializeClient(); } @Test From 0302985f217fadf643e339e68e1e9a73ed0a6c7c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:43:22 +0800 Subject: [PATCH 170/231] fix(firstleg): settle zero-package measurements safely --- .../mapper/InboundSplitSettlementMapper.java | 7 ++++--- .../PdaSplitSettlementSqlContractTest.java | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index ed0190ff2..bb29c8c8c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -205,9 +205,10 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_inbound_measurement_item - SET package_count=#{packageCount}, - total_volume_cbm=unit_volume_cbm*#{packageCount}, - volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount}, + SET total_volume_cbm=COALESCE(unit_volume_cbm,0)*#{packageCount}, + volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0 + ELSE COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0) END, + package_count=#{packageCount}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{measurementItemId} AND tenant_id=#{accountId} """) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java index 636087f87..a7703c107 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -136,6 +136,18 @@ class PdaSplitSettlementSqlContractTest { .contains("plan_item.package_count = pick_item.picked_package_count"); } + @Test + void zeroPackageSettlementNeverWritesNullVolumeWeight() throws Exception { + String update = String.join(" ", InboundSplitSettlementMapper.class.getMethod( + "updateMeasurementItemForLoaded", long.class, long.class, int.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(update) + .contains("volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0") + .contains("COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0)"); + assertThat(update.indexOf("volume_weight_kg=")).isLessThan(update.indexOf("package_count=#{packageCount}")); + } + @Test void keepsAllocationConfirmedUntilReleaseStep() throws Exception { String consume = InboundPalletAllocationMapper.class.getMethod("consume", From 72f3f9f9715a2524e36e11d996812bfbcc402209 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:46:33 +0800 Subject: [PATCH 171/231] fix(firstleg): execute carrier schedule due queries --- .../mapper/CarrierScheduleQueryMapper.java | 8 +++---- .../CarrierScheduleQuerySqlContractTest.java | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index 1f3e38eca..274bff0d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -49,8 +49,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit); @@ -59,8 +59,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper Date: Tue, 25 Aug 2026 13:59:03 +0800 Subject: [PATCH 172/231] fix(firstleg): continue same-plan loading allocations --- .../mapper/SmartLoadingCandidateMapper.java | 13 ++++++++++--- .../repository/SmartLoadingRepository.java | 4 ++-- .../firstleg/service/SmartLoadingService.java | 6 ++++-- .../SmartLoadingCandidateMapperContractTest.java | 8 +++++++- .../firstleg/service/SmartLoadingServiceTest.java | 15 ++++++++------- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 955715c40..9d1a34e60 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -79,12 +79,19 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba WHERE inbound_detail_id IS NOT NULL AND package_count>0 GROUP BY tenant_id,measurement_id,inbound_detail_id ) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id - WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT' + WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} + AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item same_plan_item + WHERE same_plan_item.loading_plan_id=#{planId} + AND same_plan_item.account_id=o.tenant_id + AND same_plan_item.inbound_id=o.id + AND same_plan_item.deleted=0 + ))) AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.inbound_detail_id=#{detailId} ORDER BY m.id DESC LIMIT 1 FOR UPDATE """) - Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId, - @Param("detailId") long detailId); + Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId, + @Param("inboundId") long inboundId,@Param("detailId") long detailId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index ab28e444c..73c5bf050 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -31,8 +31,8 @@ public class SmartLoadingRepository { @Resource private InboundOrderMapper inbounds; public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } - public Candidate candidate(long warehouseId, long inboundId, long detailId) { - return candidates.candidate(warehouseId, inboundId, detailId); + public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) { + return candidates.candidate(planId, warehouseId, inboundId, detailId); } public InboundOrderEntity inbound(long accountId, long inboundId) { return inbounds.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index a3ac10ebb..c03fafb0b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -132,7 +132,8 @@ public class SmartLoadingService { if (replay != null) return replay; LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(), + request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); @@ -307,7 +308,8 @@ public class SmartLoadingService { throw conflict("计划箱数不能低于已拣货箱数"); } ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); - Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId()); + Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(), + item.getInboundDetailId()); if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数"); requireSameChannel(booking, candidate); requireSameCustomerScope(booking, candidate); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index f651828fe..f6477f5de 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -44,7 +44,7 @@ class SmartLoadingCandidateMapperContractTest { @Test void detailUsesSameBatchRule() throws Exception { Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod( - "candidate", long.class, long.class, long.class); + "candidate", long.class, long.class, long.class, long.class); String sql = String.join(" ", detail.getAnnotation(Select.class).value()); assertThat(sql) @@ -53,6 +53,12 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT MAX(m2.id)") .contains("o.warehouse_id=#{warehouseId}") .contains("o.process_status='WAIT_SPLIT'") + .contains("o.process_status='LOADING'") + .contains("same_plan_item.loading_plan_id=#{planId}") + .contains("same_plan_item.account_id=o.tenant_id") + .contains("same_plan_item.inbound_id=o.id") + .contains("same_plan_item.deleted=0") + .doesNotContain("o.process_status IN ('WAIT_SPLIT','LOADING')") .contains("unitGrossWeightKg") .contains("unitNetWeightKg") .doesNotContain("i.unit_weight_kg") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 9b5cba633..8f39dfa00 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -242,7 +242,7 @@ class SmartLoadingServiceTest { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(5)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4)); BizException error = catchThrowableOfType(() -> service.assign(1, @@ -259,7 +259,7 @@ class SmartLoadingServiceTest { linked.setCustomerId(88L); when(repository.plan(1L)).thenReturn(plan); when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5)); BizException error = catchThrowableOfType(() -> service.assign(1L, new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); @@ -357,7 +357,7 @@ class SmartLoadingServiceTest { when(repository.booking(99)).thenReturn(booking()); when(repository.touch(1, 5, 7, "u")).thenReturn(1); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(loadingPickService.mutationSnapshot(1L)).thenReturn( new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of())); @@ -368,6 +368,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).candidate(1L, 100L, 10L, 20L); var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge); order.verify(loadingPickService).lockMutable(1L); order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u"); @@ -393,7 +394,7 @@ class SmartLoadingServiceTest { Plan result = service.assign(1, request, 7, "u"); assertThat(result.status()).isEqualTo("LOADING"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong()); } @@ -592,7 +593,7 @@ class SmartLoadingServiceTest { when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3)); when(repository.item(1L, 8L)).thenReturn(line); when(repository.booking(99L)).thenReturn(booking()); - when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10)); + when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10)); when(repository.items(1L)).thenReturn(List.of(line)); when(repository.touch(1L, 2, 7L, "u")).thenReturn(1); when(repository.update(line)).thenReturn(1); @@ -617,7 +618,7 @@ class SmartLoadingServiceTest { new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u")) .hasMessageContaining("不能低于已拣货箱数"); - verify(repository, never()).candidate(anyLong(), anyLong(), anyLong()); + verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong()); verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString()); } @@ -878,7 +879,7 @@ class SmartLoadingServiceTest { when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); when(repository.booking(99)).thenReturn(booking()); when(repository.items(1)).thenReturn(List.of()); - when(repository.candidate(100, 10, 20)).thenReturn(candidate(10)); + when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10)); when(repository.activeItem(1L, 9L, 20L)).thenReturn(null); when(repository.touch(1, 0, 7, "u")).thenReturn(1); } From 909355061521f6e5ded9647d2ea1f15912f57f13 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 13:59:58 +0800 Subject: [PATCH 173/231] test: verify complete PDA batch history --- ...e3-loading-dynamic-final-contract.test.mjs | 5 +++++ .../admin-test-env-modules/phase3-loading.mjs | 20 ++++++++++++------- .../phase3-pda-convergence.mjs | 18 +++++++++++++---- ...-pda-pending-consistency-contract.test.mjs | 8 ++++++-- .../phase3-producer-chain-contract.test.mjs | 11 ++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index 39cdbb7f6..e11b400de 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -45,6 +45,7 @@ test('default fixture publishes a second marker-owned inbound for the same-plan assert.match(entry, /orders-phase3-loading-incremental-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-incremental-fixture/) assert.match(entry, /phase3LoadingIncrementalPackageCount = 2/) + assert.match(entry, /phase3LoadingIncrementalDetailCount = 4/) assert.match(entry, /phase3LoadingIncremental: incrementalFixture/) assert.match(entry, /marker: originalMarker/) assert.doesNotMatch(entry, /phase3LoadingIncremental[\s\S]{0,300}(history|fallback|latest)/i) @@ -74,6 +75,10 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) assert.match(loading, /loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW'/) + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /expectedBatchOperationIds, incrementalInboundId/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 19dc4aa1d..4a27a4019 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -566,6 +566,10 @@ export async function run(context) { const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) + const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128) + const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128) + const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128) + const expectedBatchOperationIds = [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey] const batchPayload = { warehouseId, loadingPlanId, containerBookingId, expectedVersion: Number(claimed?.version), @@ -1021,6 +1025,8 @@ export async function run(context) { const incrementalFixture = context.createdResources?.orchestrationFixtures?.phase3LoadingIncremental const incrementalInboundId = id(incrementalFixture?.inboundId) + let skipBatchVerified = false + let partialBatchVerified = false let dynamicIncrementVerified = false if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) { context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false, @@ -1068,10 +1074,10 @@ export async function run(context) { expectedVersion: Number(skipTask?.version), inventorySnapshotToken: skipInventory?.snapshotToken, items: [{ itemId: id(skipTaskItem?.id), action: 'SKIP', version: Number(skipTaskItem?.version), reason: skipReason }], - }, { 'Idempotency-Key': `${context.marker}-exception-skip-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': skipBatchKey }) const skippedTask = context.apiSuccess(MODULE, 'SKIP 携带异常原因并原子解决待拣明细', skipBatchResponse, '', OPTIONS) const skippedTaskItem = skippedTask?.items?.find?.((item) => id(item?.id) === id(skipTaskItem?.id)) - context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, + skipBatchVerified = context.assert(MODULE, '合法 SKIP 保存零实拣事实并恢复 AWAITING_REVIEW', skipBatchResponse, Boolean(skipReason.trim()) && skippedTaskItem?.itemStatus === 'SKIPPED' && Number(skippedTaskItem?.pickedPackageCount) === 0 && skippedTask?.workflowStatus === 'AWAITING_REVIEW' && skippedTask?.taskStatus === 'LOADING', @@ -1125,10 +1131,10 @@ export async function run(context) { inventorySnapshotToken: partialInventory?.snapshotToken, items: [{ itemId: id(unresolvedTaskItem?.id), action: 'PARTIAL', finalPickedPackageCount: 1, releaseMode: 'MANUAL', locationIds: [], version: Number(unresolvedTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-exception-partial-batch`.slice(0, 128) }) + }, { 'Idempotency-Key': partialBatchKey }) const partialTask = context.apiSuccess(MODULE, '合法 PARTIAL 解决未完成明细', partialBatchResponse, '', OPTIONS) const partialTaskItem = partialTask?.items?.find?.((item) => id(item?.id) === id(unresolvedTaskItem?.id)) - context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, + partialBatchVerified = context.assert(MODULE, '部分拣货数量守恒并恢复最终可确认状态', partialBatchResponse, partialTaskItem?.itemStatus === 'PARTIAL' && Number(partialTaskItem?.plannedPackageCount) === 2 && Number(partialTaskItem?.pickedPackageCount) === 1 && partialTask?.workflowStatus === 'AWAITING_REVIEW' && partialTask?.taskStatus === 'LOADING', @@ -1226,7 +1232,6 @@ export async function run(context) { context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) - if (Number(dynamicCandidate?.availablePackageCount) < 2) return if (Number(dynamicCandidate?.availablePackageCount) >= 2 && reassignedPlanItem?.id) { const adjustResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { @@ -1259,7 +1264,7 @@ export async function run(context) { inventorySnapshotToken: refreshedDynamicInventory?.snapshotToken, items: [{ itemId: id(refreshedDynamicTaskItem?.id), action: 'PICK', pickedPackageCount: Number(reassignedPlanItem?.packageCount), releaseMode: 'MANUAL', locationIds: [], version: Number(refreshedDynamicTaskItem?.version) }], - }, { 'Idempotency-Key': `${context.marker}-dynamic-pick`.slice(0, 128) }) + }, { 'Idempotency-Key': dynamicBatchKey }) const dynamicPickedTask = context.apiSuccess(MODULE, '刷新后完成动态新增项拣货', dynamicBatchResponse, '', OPTIONS) context.assert(MODULE, '动态新增项拣完后同一任务重新进入 AWAITING_REVIEW', dynamicBatchResponse, id(dynamicPickedTask?.id) === canonicalTaskId && dynamicPickedTask?.workflowStatus === 'AWAITING_REVIEW' @@ -1341,7 +1346,8 @@ export async function run(context) { atomicBatchVerified: awaitingReview && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, - dynamicIncrementVerified, incrementalInboundId, + skipBatchVerified, partialBatchVerified, dynamicIncrementVerified, + expectedBatchOperationIds, incrementalInboundId, pdaTaskVersion: Number(stagedTask?.version), marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'LOADING', finalStatus: 'AWAITING_REVIEW', } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 40a7d541c..f76ab00e3 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -82,7 +82,9 @@ export async function run(context) { const batchOperations = Array.isArray(operations) ? operations.filter((item) => item.actionType === 'BATCH' && Boolean(item.operatorId) && Boolean(item.createdAt)) : [] - const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1 + const expectedBatchOperationIds = Array.isArray(fixture?.expectedBatchOperationIds) + ? fixture.expectedBatchOperationIds.map(id).filter(Boolean) : [] + const actualBatchOperationIds = batchOperations.map((item) => id(item.operationId)).filter(Boolean) context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -91,9 +93,13 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - batchOperations.length === expectedBatchCount - && new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount, - `expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS) + expectedBatchOperationIds.length === 4 + && new Set(expectedBatchOperationIds).size === 4 + && actualBatchOperationIds.length === 4 + && new Set(actualBatchOperationIds).size === 4 + && expectedBatchOperationIds.every((operationId) => actualBatchOperationIds.includes(operationId)) + && actualBatchOperationIds.every((operationId) => expectedBatchOperationIds.includes(operationId)), + `expectedBatchCount=${expectedBatchOperationIds.length}, actualBatchCount=${actualBatchOperationIds.length}, expectedIds=${expectedBatchOperationIds.join(',') || 'missing'}, actualIds=${actualBatchOperationIds.join(',') || 'missing'}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, @@ -127,6 +133,10 @@ export async function run(context) { context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, 'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS) } + if (fixture?.skipBatchVerified !== true || fixture?.partialBatchVerified !== true) { + context.addStep(MODULE, 'SKIP 与 PARTIAL 独立原子批次均完成', null, false, + `skipBatchVerified=${fixture?.skipBatchVerified === true}, partialBatchVerified=${fixture?.partialBatchVerified === true};不能按普通 PICK 批次代替异常处理审计`, OPTIONS) + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index 653a53ab0..96bb8c319 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -25,8 +25,12 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) - assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/) - assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.match(convergence, /actualBatchOperationIds\.length === 4/) + assert.match(convergence, /new Set\(expectedBatchOperationIds\)\.size === 4/) + assert.match(convergence, /new Set\(actualBatchOperationIds\)\.size === 4/) + assert.match(convergence, /expectedBatchOperationIds\.every[\s\S]*actualBatchOperationIds\.every/) + assert.doesNotMatch(convergence, /dynamicIncrementVerified === true \? 2 : 1/) assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/) assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) }) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8a5da135f..2f019a12c 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -406,6 +406,7 @@ test('preload and loading versions use immediate operation baselines and exact p }) test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { + const loading = source('./phase3-loading.mjs') const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs') assertOrder(container, [ @@ -426,6 +427,13 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au assert.match(container, /Boolean\(item\.operatorId\) && Boolean\(item\.createdAt\)/) assert.match(convergence, /PDA 原子批次完成审计包含 BATCH 操作/) assert.match(convergence, /item\.actionType === 'BATCH'/) + for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) { + assert.match(loading, new RegExp(key)) + } + assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) + assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) + assert.match(convergence, /expectedBatchOperationIds\.length === 4/) + assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/) }) test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { @@ -493,6 +501,9 @@ test('phase3 loading resolves legal SKIP and PARTIAL exceptions before downstrea assert.match(measurement, /detailBoxCode = `\$\{inboundCode\}#\$\{originalPackageCount\}-\$\{cartonIndex\}`/) assert.match(measurement, /items: draftItems/) assert.match(measurement, /expectedPackages === originalPackageCount/) + assert.match(loading, /skipBatchVerified = context\.assert/) + assert.match(loading, /partialBatchVerified = context\.assert/) + assert.doesNotMatch(loading, /if \(Number\(dynamicCandidate\?\.availablePackageCount\) < 2\) return/) }) test('phase3 loading fixture forces A0 through reversible public location status APIs', () => { From 5fb80d7624d5bc86bca75e9858817f46d9e28658 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:07:52 +0800 Subject: [PATCH 174/231] fix(firstleg): reuse running preload lifecycle --- .../split/service/SplitPreloadService.java | 13 ++++-- .../service/SplitPreloadServiceTest.java | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index 778d1ce6d..4162cc757 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -83,13 +83,20 @@ public class SplitPreloadService { } private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId, - String operatorName, boolean allowConfirmed) { + String operatorName, boolean running) { InboundSnapshot inbound = requireInbound(tenantId, inboundId); - requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划"); PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + boolean waitingSplit = isWaitingSplit(inbound.processStatus()); + boolean loading = "LOADING".equals(inbound.processStatus()); + if (!waitingSplit && !(running && loading)) { + throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划"); + } + if (loading && plan == null) { + throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配"); + } if (plan != null) { if (!PreloadStatus.PENDING.equals(plan.getStatus()) - && !(allowConfirmed && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { + && !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) { throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); } return; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 9050c7dd4..039693cf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -82,7 +82,7 @@ class SplitPreloadServiceTest { @Test void runningLoadingAllocationMayReuseConfirmedPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMED"); when(repository.findPlan(10, 20)).thenReturn(plan); @@ -92,9 +92,45 @@ class SplitPreloadServiceTest { verify(repository, never()).createPlan(any()); } + @Test + void runningLoadingAllocationMayReusePendingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void runningLoadingAllocationMustNotCreateMissingPreload() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + when(repository.findPlan(10, 20)).thenReturn(null); + + assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("缺少既有预装计划"); + + verify(repository, never()).createPlan(any()); + } + + @Test + void ordinaryLoadingAllocationStillRequiresWaitingSplit() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); + PreloadPlanEntity plan = new PreloadPlanEntity(); + plan.setStatus("PENDING"); + when(repository.findPlan(10, 20)).thenReturn(plan); + + assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin")) + .hasMessageContaining("仅待分柜订单"); + + verify(repository, never()).createPlan(any()); + } + @Test void runningLoadingAllocationStillRejectsConfirmingPreload() { - when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3)); PreloadPlanEntity plan = new PreloadPlanEntity(); plan.setStatus("CONFIRMING"); when(repository.findPlan(10, 20)).thenReturn(plan); From f1af749616ab7196ac2aeed9c3c8f2d875eabd29 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:18:13 +0800 Subject: [PATCH 175/231] test: target dynamic loading detail precisely --- ...e3-loading-dynamic-final-contract.test.mjs | 6 ++++++ .../admin-test-env-modules/phase3-loading.mjs | 19 ++++++++++++++++--- .../phase3-producer-chain-contract.test.mjs | 4 ++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index e11b400de..1d99d7a3f 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -56,6 +56,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi 'PDA 原子批次完成拣货并等待人工确认', '五类装柜证据完整且铅封号持久化', 'LOADING 阶段向同一 PDA task 增加入库单', + '动态新增计划明细按 inboundDetailId 唯一定位', '增量保持稳定四元身份、LOADING 不回退并撤回最终完成门禁', '增量后旧 inventory token 明确 A0409 并要求刷新', '增量旧 token 冲突后 plan/task/items 零写入', @@ -71,6 +72,11 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.match(loading, /String\(staleIncrementBatch\.body\?\.message \|\| ''\)\.includes\('库存已变化,请刷新后重试'\)/) + assert.match(loading, /incrementalPlanItems = \(incrementedPlan\?\.items \|\| \[\]\)\.filter[\s\S]*id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/) + assert.match(loading, /reassignedPlanItems = \(reassignedPlan\?\.items \|\| \[\]\)\.filter[\s\S]*id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/) + assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) + assert.doesNotMatch(loading, /incrementedPlan\?\.items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) + assert.doesNotMatch(loading, /reassignedPlan\?\.items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assert.match(loading, /id\(incrementedTask\?\.loadingPlanId\) === loadingPlanId/) assert.match(loading, /id\(incrementedTask\?\.containerBookingId\) === containerBookingId/) assert.match(loading, /const taskDetailPath = `\$\{pdaBase\}\/tasks\/\$\{canonicalTaskId\}\?warehouseId=/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 4a27a4019..9059f6a91 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1160,9 +1160,15 @@ export async function run(context) { inboundId: incrementalInboundId, inboundDetailId: id(dynamicCandidate.inboundDetailId), packageCount: 1, expectedVersion: Number(planBeforeIncrement?.version), operationId: `${context.marker}-dynamic-assign`.slice(0, 128), - }) + }) const incrementedPlan = context.apiSuccess(MODULE, 'LOADING 阶段向同一 PDA task 增加入库单', incrementResponse, '', OPTIONS) - const incrementalPlanItem = incrementedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId) + const incrementalPlanItems = (incrementedPlan?.items || []).filter((item) => + id(item?.inboundId) === incrementalInboundId + && id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId)) + const incrementalPlanItem = incrementalPlanItems.length === 1 ? incrementalPlanItems[0] : null + context.assert(MODULE, '动态新增计划明细按 inboundDetailId 唯一定位', incrementResponse, + incrementalPlanItems.length === 1 && id(incrementalPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId), + `inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${incrementalPlanItems.length}, planItemId=${id(incrementalPlanItem?.id) || 'missing'}`, OPTIONS) const preservedRemarkItem = incrementedPlan?.items?.find?.((item) => id(item?.id) === id(assignedItem.id)) const incrementedTaskResponse = await context.request('GET', taskDetailPath) const incrementedTask = context.apiSuccess(MODULE, '增量后回查稳定 PDA 四元身份', incrementedTaskResponse, '', OPTIONS) @@ -1228,7 +1234,14 @@ export async function run(context) { }) const reassignedPlan = context.apiSuccess(MODULE, '删除后重新加入同一动态入库单', reassignResponse, '', OPTIONS) let effectiveDynamicPlan = reassignedPlan - let reassignedPlanItem = reassignedPlan?.items?.find?.((item) => id(item?.inboundId) === incrementalInboundId) + const reassignedPlanItems = (reassignedPlan?.items || []).filter((item) => + id(item?.inboundId) === incrementalInboundId + && id(item?.inboundDetailId) === id(dynamicCandidate.inboundDetailId)) + let reassignedPlanItem = reassignedPlanItems.length === 1 ? reassignedPlanItems[0] : null + context.assert(MODULE, '重新加入动态明细仍按 inboundDetailId 唯一定位', reassignResponse, + reassignedPlanItems.length === 1 + && id(reassignedPlanItem?.inboundDetailId) === id(dynamicCandidate.inboundDetailId), + `inboundId=${incrementalInboundId}, inboundDetailId=${id(dynamicCandidate.inboundDetailId)}, matches=${reassignedPlanItems.length}, planItemId=${id(reassignedPlanItem?.id) || 'missing'}`, OPTIONS) context.assert(MODULE, '动态增量 fixture 至少保留两件可用数量供调整与最小已拣门禁', dynamicCandidatesResponse, Number(dynamicCandidate?.availablePackageCount) >= 2, `availablePackageCount=${dynamicCandidate?.availablePackageCount ?? 'missing'}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 2f019a12c..51e47e9e6 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -454,6 +454,10 @@ test('phase3 loading proves mixed-line rollback across task, all items and opera test('phase3 loading proves stale inventory zero-write and defers final completion audit', () => { const loading = source('./phase3-loading.mjs') assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) + assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) + assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) + assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) + assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assertOrder(loading, [ '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', '库存变化后刷新 PDA 实时库存快照', From 7eca39e4fd6ae1d82b89a53e8b8b649cc3318f83 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:24:59 +0800 Subject: [PATCH 176/231] fix(firstleg): honor terminal partial pick outcomes --- .../baiyue/firstleg/mapper/LoadingPickItemMapper.java | 10 +++++++++- .../repository/LoadingPickItemMapperContractTest.java | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index d57e01168..81650f914 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -230,7 +230,15 @@ public interface LoadingPickItemMapper extends BaseMapper WHERE pick_item.loading_plan_item_id = plan_item.id AND pick_task.loading_plan_id = #{planId} AND pick_task.task_status = 'LOADING' - AND pick_item.picked_package_count = plan_item.package_count + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = plan_item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count < plan_item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) ) """) long unfinishedCount(@Param("planId") long planId); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 47261f748..df0a6c03e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -39,7 +39,15 @@ class LoadingPickItemMapperContractTest { .contains("plan_item.deleted = 0") .contains("pick_task.loading_plan_id = #{planId}") .contains("pick_task.task_status = 'LOADING'") - .contains("pick_item.picked_package_count = plan_item.package_count"); + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.picked_package_count = plan_item.package_count") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.picked_package_count < plan_item.package_count") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.item_status = 'PENDING'") + .doesNotContain("pick_item.item_status = 'PICKING'"); } @Test From 121e8ea619ef6ae8d4f9674732a852eaf94b3f8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:33:38 +0800 Subject: [PATCH 177/231] fix(firstleg): validate settled partial pick totals --- .../com/baiyue/firstleg/mapper/LoadingPickItemMapper.java | 5 +++-- .../repository/LoadingPickItemMapperContractTest.java | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 81650f914..08ba4d734 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -216,7 +216,8 @@ public interface LoadingPickItemMapper extends BaseMapper long unresolvedCount(@Param("taskId") long taskId); /** - * 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。 + * 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。 + * PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。 */ @Select(""" SELECT COUNT(*) @@ -235,7 +236,7 @@ public interface LoadingPickItemMapper extends BaseMapper AND pick_item.picked_package_count = plan_item.package_count) OR (pick_item.item_status = 'PARTIAL' AND pick_item.picked_package_count > 0 - AND pick_item.picked_package_count < plan_item.package_count) + AND pick_item.picked_package_count = plan_item.package_count) OR (pick_item.item_status = 'SKIPPED' AND pick_item.picked_package_count = 0) ) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index df0a6c03e..0810397ec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -43,11 +43,13 @@ class LoadingPickItemMapperContractTest { .contains("pick_item.picked_package_count = plan_item.package_count") .contains("pick_item.item_status = 'PARTIAL'") .contains("pick_item.picked_package_count > 0") - .contains("pick_item.picked_package_count < plan_item.package_count") + .doesNotContain("pick_item.picked_package_count < plan_item.package_count") .contains("pick_item.item_status = 'SKIPPED'") .contains("pick_item.picked_package_count = 0") .doesNotContain("pick_item.item_status = 'PENDING'") .doesNotContain("pick_item.item_status = 'PICKING'"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = plan_item.package_count")).isEqualTo(2); } @Test From 5abc7543ae21f6403f03b95ba15d5626827a8c09 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:41:48 +0800 Subject: [PATCH 178/231] fix(firstleg): align loading completion trigger --- deploy/sql/头程数据库统一增量-MySQL8.sql | 23 +++++++++++++------ .../db/phase3-smart-loading-schema.sql | 23 +++++++++++++------ .../SmartLoadingSqlContractTest.java | 19 +++++++++++++-- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 271ad12d9..e71adf7d2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5688,14 +5688,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index bd13ba015..ab4efc31c 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -813,14 +813,23 @@ BEGIN SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3)); ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN IF EXISTS (SELECT 1 FROM fl_loading_plan_item item - LEFT JOIN fl_loading_pick_item pick_item - ON pick_item.loading_plan_item_id = item.id - LEFT JOIN fl_loading_pick_task pick_task - ON pick_task.id = pick_item.pick_task_id - AND pick_task.loading_plan_id = OLD.id WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 - AND (pick_task.id IS NULL - OR pick_item.picked_package_count <> item.package_count)) THEN + AND NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id + WHERE pick_item.loading_plan_item_id = item.id + AND pick_task.loading_plan_id = OLD.id + AND pick_task.task_status = 'LOADING' + AND ( + (pick_item.item_status = 'PICKED' + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'PARTIAL' + AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count = item.package_count) + OR (pick_item.item_status = 'SKIPPED' + AND pick_item.picked_package_count = 0) + ) + )) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜'; END IF; SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 5025d7431..27c6c17a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -208,8 +208,6 @@ class SmartLoadingSqlContractTest { .contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN") .contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") - .contains("LEFT JOIN fl_loading_pick_item pick_item") - .contains("pick_item.picked_package_count <> item.package_count") .contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN") .contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") @@ -226,6 +224,23 @@ class SmartLoadingSqlContractTest { .doesNotContain("CONFIRMED") .doesNotContain("ROLLED_BACK") .doesNotContain("WITHDRAWN"); + assertLoadingCompletionTrigger(migration()); + assertLoadingCompletionTrigger(unifiedMigration()); + } + + private static void assertLoadingCompletionTrigger(String sql) { + assertThat(sql) + .contains("AND NOT EXISTS (") + .contains("pick_task.loading_plan_id = OLD.id") + .contains("pick_task.task_status = 'LOADING'") + .contains("pick_item.item_status = 'PICKED'") + .contains("pick_item.item_status = 'PARTIAL'") + .contains("pick_item.picked_package_count > 0") + .contains("pick_item.item_status = 'SKIPPED'") + .contains("pick_item.picked_package_count = 0") + .doesNotContain("pick_item.picked_package_count <> item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.picked_package_count = item.package_count")).isEqualTo(2); } @Test From f325d57fc142c1b56b16d73b7673a2c2e074a04c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 14:46:06 +0800 Subject: [PATCH 179/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 13 + AGENTS.md | 1 + Jenkinsfile | 4 +- Jenkinsfile.service | 3 +- README.md | 2 +- common-web/AGENTS.md | 2 + common-web/pom.xml | 5 + ...eignTracePropagationAutoConfiguration.java | 18 ++ .../web/FeignTraceRequestInterceptor.java | 41 +++ .../com/baiyue/common/web/TraceParent.java | 8 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../common/web/CorrelationIdFilterTest.java | 2 + ...TracePropagationAutoConfigurationTest.java | 37 +++ .../web/FeignTraceRequestInterceptorTest.java | 80 +++++ .../baiyue/common/web/TraceParentTest.java | 8 + .../nacos/overseas/event-bridge-service.yml | 1 + config/nacos/overseas/gateway-overseas.yml | 6 + .../overseas/overseas-security-service.yml | 7 + deploy/docker-compose.overseas.yml | 16 + deploy/scripts/deploy-built-service.sh | 13 +- deploy/scripts/deploy-services.sh | 15 +- deploy/service-map.yml | 5 + deploy/sql/README.md | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 32 +- docs/同步调用依赖与失败传播治理基线.md | 75 ++++- docs/头程项目目录结构与服务说明.md | 6 +- event-bridge-service/AGENTS.md | 4 +- .../docs/development-observability-slo.md | 67 +++++ .../eventbridge/mapper/BridgeInboxMapper.java | 18 ++ .../EventBridgeInboxMetricsSampler.java | 103 +++++++ .../metrics/EventBridgeMetrics.java | 121 ++++++++ .../metrics/InboxMetricsSnapshot.java | 50 ++++ .../service/EventInboxService.java | 5 + .../service/EventReplayService.java | 26 ++ .../service/HttpFirstlegEventClient.java | 24 +- .../HttpSecurityProjectionEventClient.java | 56 ++++ .../service/OverseasEventDispatcher.java | 49 ++- .../SecurityProjectionEventClient.java | 7 + .../src/main/resources/application.yml | 1 + .../EventBridgeInboxMetricsSamplerTest.java | 54 ++++ .../metrics/EventBridgeMetricsTest.java | 58 ++++ .../EventBridgeObservabilityContractTest.java | 41 +++ .../service/EventInboxServiceTest.java | 14 + .../service/EventReplayServiceTest.java | 23 ++ .../service/HttpFirstlegEventClientTest.java | 26 ++ .../service/OverseasEventDispatcherTest.java | 128 +++++++- .../mapper/LoadingPickItemMapper.java | 18 +- .../LoadingCompletedOutboxRepository.java | 2 + .../LoadingCompletedOutboxRepositoryTest.java | 38 +++ .../LoadingPickItemMapperContractTest.java | 25 ++ gateway-overseas/AGENTS.md | 1 + .../gateway/config/GatewaySecurityConfig.java | 3 +- .../OverseasSecurityProjectionProperties.java | 17 ++ .../HttpOverseasSecurityValidationClient.java | 37 +++ .../OverseasSecurityProjectionFilter.java | 74 +++++ .../OverseasSecurityValidationClient.java | 8 + .../src/main/resources/application.yml | 4 + .../OverseasSecurityProjectionFilterTest.java | 83 ++++++ ...astleg-internal-modularization-baseline.md | 199 +++++++++++++ .../scripts/analyze-lastleg-dependencies.mjs | 43 ++- .../analyze-lastleg-dependencies.test.mjs | 76 +++++ .../config/LastlegRestClientFactory.java | 19 +- .../controller/LastlegOrderController.java | 10 +- .../in/web/PlatformVehicleController.java | 103 +++++++ .../out/persistence}/VehicleMapper.java | 7 +- .../VehiclePersistenceAdapter.java | 121 ++++++++ .../out/persistence/VehicleRecord.java} | 4 +- .../VehicleApplicationService.java | 165 ++++++++++ .../model/VehicleModels.java} | 6 +- .../port/out/VehicleRepository.java | 45 +++ .../controller/PlatformVehicleController.java | 58 ---- .../vehicle/service/VehicleService.java | 143 --------- .../UnloadCompletedOutboxRepository.java | 2 + .../service/LoadingCompletedConsumer.java | 7 + .../ContainerShippingNodeConsumer.java | 6 + .../config/LastlegRestClientFactoryTest.java | 84 ++++++ .../LastlegOrderControllerTest.java | 15 + ...PlatformVehicleControllerContractTest.java | 2 +- .../VehicleApplicationServiceTest.java | 64 ++++ .../vehicle/service/VehicleServiceTest.java | 63 ---- .../UnloadCompletedOutboxRepositoryTest.java | 38 +++ .../service/LoadingCompletedConsumerTest.java | 2 + .../ContainerShippingNodeConsumerTest.java | 15 + .../lastleg-slice-dependencies.json | 57 ++++ overseas-security-service/AGENTS.md | 14 + overseas-security-service/pom.xml | 22 ++ .../OverseasSecurityApplication.java | 11 + .../OverseasSecurityController.java | 52 ++++ .../dto/OverseasSecurityDtos.java | 28 ++ .../security/InternalServiceTokenGuard.java | 26 ++ .../OverseasSecurityValidationService.java | 44 +++ .../service/ProjectionMonotonicity.java | 19 ++ .../SecurityProjectionRedisMirror.java | 36 +++ .../service/SecurityProjectionService.java | 153 ++++++++++ .../src/main/resources/application.yml | 28 ++ .../main/resources/db/overseas-security.sql | 31 ++ .../internal-overseas-security-v1.yaml | 26 ++ .../OverseasSecurityContractTest.java | 51 ++++ ...OverseasSecurityValidationServiceTest.java | 36 +++ .../service/ProjectionMonotonicityTest.java | 28 ++ pom.xml | 29 ++ scripts/java-topology-contract.test.mjs | 34 ++- scripts/overseas-deployment-contract.test.mjs | 9 +- .../provision-development-service-jobs.groovy | 2 +- scripts/synchronous-dependency-baseline.mjs | 230 ++++++++++++++ .../synchronous-dependency-contract.test.mjs | 281 +++++++++++------- 106 files changed, 3682 insertions(+), 450 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java create mode 100644 config/nacos/overseas/overseas-security-service.yml create mode 100644 event-bridge-service/docs/development-observability-slo.md create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java create mode 100644 gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java create mode 100644 lastleg-service/docs/lastleg-internal-modularization-baseline.md create mode 100644 lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{mapper => adapter/out/persistence}/VehicleMapper.java (55%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{entity/Vehicle.java => adapter/out/persistence/VehicleRecord.java} (88%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java rename lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/{dto/VehicleDtos.java => application/model/VehicleModels.java} (94%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java rename lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/{controller => adapter/in/web}/PlatformVehicleControllerContractTest.java (96%) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java create mode 100644 lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json create mode 100644 overseas-security-service/AGENTS.md create mode 100644 overseas-security-service/pom.xml create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java create mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java create mode 100644 overseas-security-service/src/main/resources/application.yml create mode 100644 overseas-security-service/src/main/resources/db/overseas-security.sql create mode 100644 overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java create mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java create mode 100644 scripts/synchronous-dependency-baseline.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f05dade2c..865a9e4b2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,6 +29,18 @@ OpenAPI有效性: - npm test --prefix scripts/openapi-validation - npm run validate --prefix scripts/openapi-validation +静态分析报告: + stage: verify + allow_failure: true + script: + - mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify + artifacts: + when: always + expire_in: 7 days + paths: + - "**/target/spotbugs*.xml" + - "**/target/site/spotbugs.html" + 校验与测试: stage: verify script: @@ -64,6 +76,7 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar + - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index 2b612f218..5a0872a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | diff --git a/Jenkinsfile b/Jenkinsfile index a06ba1017..be2593cce 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,6 +17,7 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', + 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -68,6 +69,7 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') + booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index bd81515d2..88c8bac39 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,6 +17,7 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', + 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index d8202d879..604e465ae 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 19fdeacb0..d2312b7cf 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -12,6 +12,7 @@ | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | +| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -25,5 +26,6 @@ - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 +- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/pom.xml b/common-web/pom.xml index fa892aee9..3a5a14627 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -39,6 +39,11 @@ io.micrometer micrometer-registry-prometheus + + io.github.openfeign + feign-core + true + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..e4223e528 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Enables synchronous Feign trace propagation without making Feign a required dependency. */ +@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration") +@ConditionalOnClass(name = "feign.RequestInterceptor") +public class FeignTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(FeignTraceRequestInterceptor.class) + FeignTraceRequestInterceptor feignTraceRequestInterceptor() { + return new FeignTraceRequestInterceptor(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java new file mode 100644 index 000000000..88303c069 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.util.regex.Pattern; + +/** Propagates the trusted synchronous request trace context to every Feign invocation. */ +public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + public void apply(RequestTemplate template) { + removeManagedHeaders(template); + + String requestId = MDC.get(CorrelationContext.REQUEST_ID); + if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) { + template.header(CorrelationContext.REQUEST_ID_HEADER, requestId); + } + + String traceId = MDC.get(CorrelationContext.TRACE_ID); + if (TraceParent.validLegacyTraceId(traceId)) { + TraceParent outbound = TraceParent.outboundChild(traceId); + template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value()); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private void removeManagedHeaders(RequestTemplate template) { + template.removeHeader(CorrelationContext.REQUEST_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2fbd3a1ab..2ece6f081 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -55,6 +55,14 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(traceId, randomNonZero(8), "00"); } + /** Creates a sampled outbound child for a trusted current trace ID. */ + public static TraceParent outboundChild(String currentTraceId) { + if (!validLegacyTraceId(currentTraceId)) { + throw new IllegalArgumentException("Current trace ID is invalid"); + } + return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 66426422f..06a277bae 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration +com.baiyue.common.web.FeignTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index c73051fbe..67d4bd3be 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -33,6 +33,8 @@ class CorrelationIdFilterTest { .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull(); + assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull(); } @Test diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..2aeec044f --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class)); + + @Test + void registersTraceInterceptorWhenFeignIsPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class); + assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1); + }); + } + + @Test + void doesNotLoadFeignConfigurationWhenFeignIsAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor"); + }); + } + + @Test + void backsOffOnlyForAnApplicationProvidedTraceInterceptor() { + contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java new file mode 100644 index 000000000..d87b3d445 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java @@ -0,0 +1,80 @@ +package com.baiyue.common.web; + +import feign.RequestTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTraceRequestInterceptorTest { + private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void runsLastSoTrustedContextHasFinalHeaderAuthority() { + assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-trusted-123"); + assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER)) + .singleElement().asString() + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void createsDifferentNonZeroSpanForEachSynchronousInvocation() { + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate first = new RequestTemplate(); + RequestTemplate second = new RequestTemplate(); + + interceptor.apply(first); + interceptor.apply(second); + + assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER)) + .isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER)); + } + + @Test + void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() { + MDC.put(CorrelationContext.REQUEST_ID, "invalid request id"); + MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers()).doesNotContainKeys( + CorrelationContext.REQUEST_ID_HEADER, + CorrelationContext.TRACE_ID_HEADER, + CorrelationContext.TRACE_PARENT_HEADER); + } + + private RequestTemplate templateWithSpoofedHeaders() { + RequestTemplate template = new RequestTemplate(); + template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter"); + template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + template.header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + return template; + } + + private String singleHeader(RequestTemplate template, String name) { + return template.headers().get(name).iterator().next(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index c97edfa34..e59be28aa 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -43,4 +43,12 @@ class TraceParentTest { assertThat(root.value()).matches( "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); } + + @Test + void createsSampledOutboundChildForTrustedCurrentTrace() { + TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736"); + + assertThat(child.value()).matches( + "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } } diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 27c492a4b..bbc198d5c 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,6 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index e16f76e10..11e2deb34 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -1,3 +1,9 @@ springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml new file mode 100644 index 000000000..80565c445 --- /dev/null +++ b/config/nacos/overseas/overseas-security-service.yml @@ -0,0 +1,7 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 5eb84191d..f41f67f15 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,6 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} + OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} + OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -92,11 +94,25 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] + overseas-security-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9307 + OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} + OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} + OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} + networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f20ca9be2..b8d703d8c 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -67,7 +67,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -101,6 +101,15 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi +if [ "$SERVICE" = "overseas-security-service" ]; then + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -118,7 +127,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 7c9983f82..abdbafef5 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,6 +80,17 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi +case " $SERVICES " in + *" overseas-security-service "*) + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..e78b8a6e5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,6 +8,11 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] + overseas-security-service: + artifactId: overseas-security-service + jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar + port: 9307 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b81a56b25..dc681dba9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,7 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql` 与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口同时升级 `baiyue_lastleg` 与 `baiyue_lastlag_warehouse`,当前包含海外主数据、业务、入仓库存与海外仓托盘兼容结构,后续继续追加到同一入口,不按日期拆分。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index afb0c1055..41f10d449 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -919,4 +919,34 @@ END$$ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; + +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, + payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, + received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, + security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, + payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, + changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_security_projection_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' + AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_security_projection_ready(); +DROP PROCEDURE assert_overseas_security_projection_ready; +SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 5755b0d21..0562b97bb 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -11,7 +11,7 @@ - `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; - `EXTERNAL`:Integration 对第三方供应商的调用。 -离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 +逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 ## 当前服务边 @@ -34,7 +34,7 @@ | Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | | File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | | File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | -| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 | +| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` | | Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | | Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | @@ -45,28 +45,73 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 | -| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 | -| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 | +| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | +| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | +| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | -## 依赖环基线 +#### Lastleg 四个有界 RestClient -按服务边计算,当前唯一非平凡强连通分量包含 8 个服务: +四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 + +| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | +|---|---|---|---| +| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | +| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | + +## 分上下文依赖图 + +### 在线 REQUEST 图 + +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 + +当前在线强连通分量为: + +`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` + +合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 + +### 后台 BACKGROUND 图 + +后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: + +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` + +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 + +### 全图 + +全上下文合并后,非平凡强连通分量仍为 8 个服务: `Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` -其中优先关注的直接双向边为: +全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。 -1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖; -2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递; -3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询; -4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照; -5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加; -6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。 +### 用户入口在线最长简单路径 -强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。 +默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算: + +| 用户入口 | 最长跳数 | 全部长路径 | +|---|---:|---| +| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | +| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | +| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | +| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | +| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | +| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | +| 国内仓储 API | 0 | 无下游同步调用 | +| 海外仓储 API | 0 | 无下游同步调用 | + +这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 + +### 第一个建议退出的 REQUEST 边 + +建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 ## 退出顺序 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 0fad3c9d3..2ed49423a 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,6 +48,7 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 +├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -101,6 +102,7 @@ baiyue-firstleg/ | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -281,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 75fe1f706..14ed05d26 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,11 +12,12 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | +| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -27,6 +28,7 @@ - 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 +- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 ## 修改与验证 diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md new file mode 100644 index 000000000..1c05c13ce --- /dev/null +++ b/event-bridge-service/docs/development-observability-slo.md @@ -0,0 +1,67 @@ +# Event Bridge 开发阶段可观测性与 SLO 合同 + +## 状态与边界 + +本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了 +Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。 + +指标标签只允许 `target_region`、`event_type`、`result`: + +- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。 +- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。 +- `result` 仅为代码中冻结的接收或传输结果枚举。 +- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。 + +## 指标合同 + +| Prometheus 指标 | 含义 | +|---|---| +| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` | +| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` | +| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 | +| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 | +| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 | +| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 | + +`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数, +不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。 + +Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge, +不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有 +`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。 + +## 开发阶段 SLI 与 SLO + +SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT` +不重复进入分母。滚动 30 天目标: + +- 99% 的新接收跨区事件在 60 秒内得到下游确认。 +- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。 + +代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组: + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99 +``` + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999 +``` + +## 告警合同 + +规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同: + +- 新增 DEAD:下一次评估立即告警,不设置持续等待。 + `sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0` +- 最老待处理年龄预警:任一有限标签序列超过 120 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120` +- 最老待处理年龄严重:任一有限标签序列超过 900 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900` +- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。 + +告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 568192832..287a5faa0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -2,6 +2,7 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper { WHERE event_id=#{eventId} AND status='DEAD' """) int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); + + /** + * Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume; + * Prometheus collection never invokes this query directly. + */ + @Select(""" + SELECT target_region AS targetRegion, event_type AS eventType, + SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount, + COALESCE(TIMESTAMPDIFF(MICROSECOND, + MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END), + CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds, + SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount + FROM bridge_inbox + WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD') + GROUP BY target_region, event_type + """) + List observabilitySnapshots(); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java new file mode 100644 index 000000000..667aaadb2 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java @@ -0,0 +1,103 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */ +@Component +@Slf4j +public class EventBridgeInboxMetricsSampler { + public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog"; + public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds"; + public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead"; + + private final BridgeInboxMapper inboxMapper; + private final MultiGauge backlog; + private final MultiGauge oldestAge; + private final MultiGauge dead; + + public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) { + this.inboxMapper = inboxMapper; + this.backlog = MultiGauge.builder(BACKLOG_METRIC) + .description("Events waiting for downstream acknowledgement").register(registry); + this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC) + .description("Age in seconds of the oldest pending Inbox event").register(registry); + this.dead = MultiGauge.builder(DEAD_METRIC) + .description("Current number of DEAD Inbox events").register(registry); + } + + @Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}", + fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}") + public void sample() { + final List snapshots; + try { + snapshots = inboxMapper.observabilitySnapshots(); + } catch (RuntimeException exception) { + log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception); + return; + } + Map aggregates = aggregate(snapshots); + backlog.register(rows(aggregates, MetricValue.BACKLOG), true); + oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true); + dead.register(rows(aggregates, MetricValue.DEAD), true); + } + + private Map aggregate(List snapshots) { + Map result = new LinkedHashMap<>(); + if (snapshots == null) { + return result; + } + for (InboxMetricsSnapshot snapshot : snapshots) { + MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()), + EventBridgeMetrics.eventType(snapshot.getEventType())); + result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot); + } + return result; + } + + private List> rows(Map aggregates, MetricValue value) { + List> rows = new ArrayList<>(); + aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of( + Tags.of("target_region", key.targetRegion, "event_type", key.eventType), + value.value(aggregate)))); + return rows; + } + + private record MetricKey(String targetRegion, String eventType) { } + + private static final class Aggregate { + private long backlog; + private double oldestAge; + private long dead; + + void add(InboxMetricsSnapshot snapshot) { + backlog += snapshot.getBacklogCount(); + oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds()); + dead += snapshot.getDeadCount(); + } + } + + private enum MetricValue { + BACKLOG { + @Override Number value(Aggregate aggregate) { return aggregate.backlog; } + }, + OLDEST_AGE { + @Override Number value(Aggregate aggregate) { return aggregate.oldestAge; } + }, + DEAD { + @Override Number value(Aggregate aggregate) { return aggregate.dead; } + }; + + abstract Number value(Aggregate aggregate); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java new file mode 100644 index 000000000..50a3bbe66 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,121 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +/** Low-cardinality transport metrics for the cross-region event bridge. */ +@Component +@Slf4j +public class EventBridgeMetrics { + public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; + public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; + public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; + + private static final Set KNOWN_EVENT_TYPES = Set.of( + EventTypes.ORDER_APPROVED, + EventTypes.ORDER_CANCELLED, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED, + EventTypes.LAST_MILE_HANDOFF_ACCEPTED, + EventTypes.LAST_MILE_HANDOFF_REJECTED, + EventTypes.LAST_MILE_MILESTONE_CHANGED, + EventTypes.LAST_MILE_COMPLETED, + EventTypes.TRACKING_MILESTONE_PROJECTED); + + private final MeterRegistry registry; + private final Clock clock; + + public EventBridgeMetrics(MeterRegistry registry) { + this(registry, Clock.systemDefaultZone()); + } + + EventBridgeMetrics(MeterRegistry registry, Clock clock) { + this.registry = registry; + this.clock = clock; + } + + public void recordReceive(EventEnvelope envelope, ReceiveResult result) { + try { + registry.counter(RECEIVE_METRIC, + "target_region", targetRegion(envelope.targetRegion().name()), + "event_type", eventType(envelope.eventType()), + "result", result.name()).increment(); + } catch (RuntimeException exception) { + log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception); + } + } + + public void recordTransport(BridgeInbox event, TransportResult result) { + try { + String targetRegion = targetRegion(event.getTargetRegion()); + String eventType = eventType(event.getEventType()); + registry.counter(TRANSPORT_METRIC, + "target_region", targetRegion, + "event_type", eventType, + "result", result.name()).increment(); + if (result.acknowledged() && event.getReceivedAt() != null) { + Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock)); + if (latency.isNegative()) { + latency = Duration.ZERO; + } + Timer.builder(DELIVERY_LATENCY_METRIC) + .description("Time from Event Bridge receipt to downstream acknowledgement") + .serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15)) + .tags("target_region", targetRegion, "event_type", eventType) + .register(registry).record(latency); + } + } catch (RuntimeException exception) { + log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception); + } + } + + public static String targetRegion(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER"; + } + + public static String eventType(String value) { + return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER"; + } + + public enum ReceiveResult { + RECEIVED, + DUPLICATE, + CONFLICT + } + + public enum TransportResult { + DELIVERED(true), + RETRY(false), + DEAD(false), + ACKNOWLEDGED_LEASE_LOST(true), + FAILED_LEASE_LOST(false), + MANUAL_REPLAY(false); + + private final boolean acknowledged; + + TransportResult(boolean acknowledged) { + this.acknowledged = acknowledged; + } + + boolean acknowledged() { + return acknowledged; + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java new file mode 100644 index 000000000..24e89b303 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java @@ -0,0 +1,50 @@ +package com.baiyue.eventbridge.metrics; + +/** Result row of the bounded, scheduled Inbox observability aggregation. */ +public class InboxMetricsSnapshot { + private String targetRegion; + private String eventType; + private long backlogCount; + private double oldestPendingAgeSeconds; + private long deadCount; + + public String getTargetRegion() { + return targetRegion; + } + + public void setTargetRegion(String targetRegion) { + this.targetRegion = targetRegion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public long getBacklogCount() { + return backlogCount; + } + + public void setBacklogCount(long backlogCount) { + this.backlogCount = backlogCount; + } + + public double getOldestPendingAgeSeconds() { + return oldestPendingAgeSeconds; + } + + public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) { + this.oldestPendingAgeSeconds = oldestPendingAgeSeconds; + } + + public long getDeadCount() { + return deadCount; + } + + public void setDeadCount(long deadCount) { + this.deadCount = deadCount; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 25b7fc7c3..f4adf8f3d 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -5,6 +5,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -23,6 +24,7 @@ import java.util.Objects; public class EventInboxService { @Resource private InboxStore inboxStore; @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; @Value("${baiyue.region:CN}") private String localRegion; @@ -44,6 +46,7 @@ public class EventInboxService { BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); @@ -56,8 +59,10 @@ public class EventInboxService { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { if (!sameEnvelope(existing, envelope)) { + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java index bc8b76dea..632672e95 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -1,13 +1,23 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service public class EventReplayService { @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private EventBridgeMetrics metrics; + @Transactional public void replayDead(String eventId, String operatorId, String reason) { if (operatorId == null || operatorId.isBlank()) { throw new IllegalArgumentException("人工补投必须提供操作人"); @@ -19,6 +29,22 @@ public class EventReplayService { if (inboxMapper.replayDead(eventId, auditReason) != 1) { throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); } + BridgeInbox event = inboxMapper.selectById(eventId); + if (event == null) { + throw new IllegalStateException("事件补投状态已更新但事件记录不存在"); + } + LocalDateTime now = LocalDateTime.now(); + BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt(); + audit.setEventId(eventId); + audit.setSourceRegion(event.getSourceRegion()); + audit.setTargetRegion(event.getTargetRegion()); + audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId)); + audit.setStatus("MANUAL_REPLAY"); + audit.setErrorMessage(auditReason); + audit.setStartedAt(now); + audit.setFinishedAt(now); + attemptMapper.insert(audit); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY); } private static String abbreviate(String value, int maxLength) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index 0aacdc3f3..fe26409f2 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -42,11 +42,31 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { .header("X-Internal-Service-Token", serviceToken) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } if (response.statusCode() / 100 != 2 || responseBody == null || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); } return String.valueOf(response.statusCode()); } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java new file mode 100644 index 000000000..e18cf8714 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -0,0 +1,56 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { + private final ObjectMapper objectMapper; + private final EventDispatcherProperties properties; + @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) { + this.objectMapper = objectMapper; + this.properties = properties; + } + + @PostConstruct void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body; + try { body = objectMapper.readTree(response.body()); } + catch (Exception exception) { body = null; } + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !row.getEventId().equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index b795a7019..54cd77867 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -1,10 +1,12 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Component; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; +import java.util.Set; import java.util.UUID; @Component @@ -21,7 +24,9 @@ public class OverseasEventDispatcher { @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; @Resource private FirstlegEventClient firstlegEventClient; + @Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeMetrics metrics; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") @@ -40,26 +45,52 @@ public class OverseasEventDispatcher { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + String responseCode; try { - String responseCode = deliver(event); - if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { - throw new IllegalStateException("事件租约已失效,无法确认投递结果"); - } - finishAttempt(attempt, "DELIVERED", responseCode, null); + responseCode = deliver(event); } catch (Exception exception) { String error = abbreviate(exception.getMessage(), 1000); - inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); - finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), + backoffSeconds(retryAttempt), error); + String attemptStatus = marked == 1 + ? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY") + : "FAILED_LEASE_LOST"; + finishAttempt(attempt, attemptStatus, null, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, error); + return; } + + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; + finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + return; + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); } private String deliver(BridgeInbox event) throws Exception { - return "CN".equals(event.getTargetRegion()) - ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + String eventType = event.getEventType(); + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); + } + return switch (event.getTargetRegion()) { + case "CN" -> firstlegEventClient.deliver(event); + case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) + ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); + default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); + }; } + private static final Set SECURITY_EVENT_TYPES = Set.of( + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java new file mode 100644 index 000000000..87fd9e3eb --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface SecurityProjectionEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index e80450011..fc5524546 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,6 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java new file mode 100644 index 000000000..a18746bd5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class EventBridgeInboxMetricsSamplerTest { + + @Test + void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() { + BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + 7, 121.5, 2); + InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1); + when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown)); + EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry); + + sampler.sample(); + + verify(mapper).observabilitySnapshots(); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC, + "OTHER", "OTHER")).isEqualTo(1.0); + verifyNoMoreInteractions(mapper); + } + + private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog, + double oldestAge, long dead) { + InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot(); + snapshot.setTargetRegion(region); + snapshot.setEventType(eventType); + snapshot.setBacklogCount(backlog); + snapshot.setOldestPendingAgeSeconds(oldestAge); + snapshot.setDeadCount(dead); + return snapshot; + } + + private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) { + return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java new file mode 100644 index 000000000..39a229241 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -0,0 +1,58 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +class EventBridgeMetricsTest { + + @Test + void collapsesUnboundedRegionAndEventTypeLabelsToOther() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("customer-controlled-region"); + event.setEventType("dynamic-event-123456"); + + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY); + + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY") + .counter().count()).isEqualTo(1.0); + assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) + .noneMatch(tag -> tag.getValue().contains("dynamic-event"))); + assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER"); + } + + @Test + void everyFrozenEventTypeHasABoundedTagValue() { + assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields()) + .filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) + .map(field -> { + try { + return (String) field.get(null); + } catch (IllegalAccessException exception) { + throw new AssertionError(exception); + } + })).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value)); + } + + @Test + void metricsFailureDoesNotChangeTransportControlFlow() { + EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class)); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("CN"); + event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED); + + assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY)) + .doesNotThrowAnyException(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java new file mode 100644 index 000000000..5c106df15 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeObservabilityContractTest { + + @Test + void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception { + Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots") + .getAnnotation(Select.class); + String query = String.join(" ", annotation.value()).replaceAll("\\s+", " "); + String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql")); + + assertThat(query) + .contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')") + .contains("GROUP BY target_region, event_type") + .doesNotContain("event_id AS", "customer_id AS", "last_error AS"); + assertThat(schema).contains( + "KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)"); + } + + @Test + void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception { + String contract = Files.readString(Path.of("docs/development-observability-slo.md")); + + assertThat(contract) + .contains("99% 的新接收跨区事件在 60 秒内") + .contains("99.9% 的新接收跨区事件在 15 分钟内") + .contains("result=\"DEAD\"", "> 120", "> 900") + .contains("不表示任何环境已经部署") + .contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)") + .doesNotContain("customer_id\"", "event_id\""); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 9ae12a6dc..c8ee93232 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -5,7 +5,9 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; @@ -24,6 +26,8 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventInboxService service = service("OVERSEAS"); @Test @@ -33,6 +37,7 @@ class EventInboxServiceTest { assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); } @Test @@ -57,6 +62,7 @@ class EventInboxServiceTest { when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); + assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } @Test @@ -67,6 +73,7 @@ class EventInboxServiceTest { assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); + assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); } @Test @@ -113,10 +120,17 @@ class EventInboxServiceTest { EventInboxService service = new EventInboxService(); ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); ReflectionTestUtils.setField(service, "localRegion", region); return service; } + private double receiveCount(String result) { + return registry.get(EventBridgeMetrics.RECEIVE_METRIC) + .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) + .counter().count(); + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java index 9bd52210b..49c00c1c8 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -1,6 +1,11 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.apache.ibatis.annotations.Update; @@ -11,15 +16,31 @@ import static org.mockito.Mockito.*; class EventReplayServiceTest { private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventReplayService service = service(); @Test void requeuesDeadEventWithAuditReason() { when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + BridgeInbox event = new BridgeInbox(); + event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); + event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + when(mapper.selectById("event-1")).thenReturn(event); + when(mapper.nextAttemptNo("event-1")).thenReturn(4); service.replayDead("event-1", "operator-7", "incident-42 approved"); verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).insert(captor.capture()); + assertThat(captor.getValue().getAttemptNo()).isEqualTo(4); + assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY"); + assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42"); + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OVERSEAS", "event_type", event.getEventType(), + "result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0); } @Test @@ -43,6 +64,8 @@ class EventReplayServiceTest { private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); + ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); return service; } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java new file mode 100644 index 000000000..69ed8424a --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpFirstlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} + """); + + assertThat(HttpFirstlegEventClient.failureMessage(200, body)) + .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutRawBody() { + assertThat(HttpFirstlegEventClient.failureMessage(503, null)) + .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 01468d95a..aa9f11d14 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -1,10 +1,13 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -21,10 +25,14 @@ import static org.mockito.Mockito.*; class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final LastlegEventClient eventClient = mock(LastlegEventClient.class); - private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); + private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = + mock(com.baiyue.eventbridge.service.LastlegEventClient.class); + private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = + mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; + private SimpleMeterRegistry registry; @BeforeEach void setUp() { @@ -33,16 +41,21 @@ class OverseasEventDispatcherTest { Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), Duration.ofSeconds(3), Duration.ofSeconds(10)); dispatcher = new OverseasEventDispatcher(); + registry = new SimpleMeterRegistry(); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); + ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); event = new BridgeInbox(); event.setEventId("event-1"); + event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); event.setRetryCount(0); + event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); when(inboxMapper.selectById("event-1")).thenReturn(event); when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); @@ -56,27 +69,50 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + verify(eventClient).deliver(event); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + assertThat(transportCount("DELIVERED")).isEqualTo(1.0); + assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); } @Test void failedDeliveryIsScheduledForRetry() throws Exception { when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down")); assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + assertThat(transportCount("RETRY")).isEqualTo(1.0); } @Test void finalFailureIsMovedToDeadState() throws Exception { event.setRetryCount(2); when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down")); assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + assertThat(transportCount("DEAD")).isEqualTo(1.0); + } + + @Test + void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception { + event.setRetryCount(4); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down")); } @Test @@ -92,6 +128,7 @@ class OverseasEventDispatcherTest { @Test void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); event.setSourceRegion("OVERSEAS"); event.setTargetRegion("CN"); when(firstlegEventClient.deliver(event)).thenReturn("200"); @@ -101,9 +138,87 @@ class OverseasEventDispatcherTest { verify(firstlegEventClient).deliver(event); verifyNoInteractions(eventClient); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { + for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); + event.setEventType(eventType); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + when(securityProjectionEventClient.deliver(event)).thenReturn("200"); + + dispatcher.dispatchOne("event-1"); + + verify(securityProjectionEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + } + } + + @Test + void unsupportedTargetRegionIsNeverMisroutedToLastleg() { + event.setTargetRegion("UNKNOWN"); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { + event.setEventType(null); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), + eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("RETRY"); + assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); + } + + @Test + void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); + assertThat(attempt.getResponseCode()).isEqualTo("200"); + assertThat(attempt.getErrorMessage()).contains("租约已失效"); + assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); + assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); + } + @Test void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { for (String target : List.of("OVERSEAS", "CN")) { @@ -162,4 +277,11 @@ class OverseasEventDispatcherTest { verify(attemptMapper).updateById(captor.capture()); return captor.getValue(); } + + private double transportCount(String result) { + return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", event.getTargetRegion(), + "event_type", event.getEventType(), "result", result) + .counter().count(); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 08ba4d734..a39cba50e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("operatorName") String operatorName); @Select(""" - SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount, + SELECT COALESCE(SUM(CASE + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count + THEN pick_item.picked_package_count + ELSE plan_item.package_count + END),0) expectedCount, COALESCE(SUM(pick_item.picked_package_count),0) pickedCount, COUNT(DISTINCT pick_task.id) taskCount, COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount, - SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + SUM(CASE + WHEN pick_item.id IS NULL THEN 1 + WHEN pick_item.item_status='PICKED' + AND pick_item.picked_package_count=plan_item.package_count THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count THEN 0 + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + ELSE 1 + END) unresolvedCount FROM fl_loading_plan_item plan_item JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index 89d6f427f..403f5a5c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -34,6 +34,8 @@ public class LoadingCompletedOutboxRepository { .eq(InboundOutboxEntity::getId, id) .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..4ee884bc3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundOutboxEntity.class); + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "publish_status", "locked_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 0810397ec..6a91c556b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest { assertThat(progress.getUnresolvedCount()).isEqualTo(1); } + @Test + void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod( + "selectInboundProgress", long.class, long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0") + .contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0") + .contains("AND pick_item.picked_package_count<=plan_item.package_count") + .contains("THEN pick_item.picked_package_count") + .contains("ELSE plan_item.package_count") + .contains("WHEN pick_item.id IS NULL THEN 1") + .contains("WHEN pick_item.item_status='PICKED'") + .contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0") + .contains("ELSE 1") + .doesNotContain("pick_item.picked_package_count<>plan_item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='PARTIAL'")).isEqualTo(2); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='SKIPPED'")).isEqualTo(2); + assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0") + .doesNotContain("pick_item.item_status='PICKING' THEN 0"); + } + @Test void completionCheckCoversAllActivePlanItems() throws Exception { Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 72487d3aa..ddd4f2c28 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -14,6 +14,7 @@ | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | | PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | | 安全配置项 | `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 f9191717c..a23d519e5 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,8 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class}) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, + OverseasSecurityProjectionProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java new file mode 100644 index 000000000..9edcfad81 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -0,0 +1,17 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.security.overseas-projection") +public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { + public OverseasSecurityProjectionProperties { + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout; + serviceUrl = serviceUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java new file mode 100644 index 000000000..6d75282df --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +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; + +@Component +public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { + private static final String PATH = "/internal/overseas-security/v1/validate"; + private final WebClient client; + private final OverseasSecurityProjectionProperties properties; + + public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) { + this.client = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { + JsonNode data = body == null ? null : body.path("data"); + if (data == null || data.isMissingNode() || !data.has("valid")) { + return Mono.error(new IllegalStateException("海外安全投影响应无效")); + } + return Mono.just(new Result(data.path("valid").asBoolean(false), + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + }); + } + + private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java new file mode 100644 index 000000000..cdbb4e727 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,74 @@ +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; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); + return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(SecurityProjectionFailure.class, + failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = number(authentication.getToken().getSubject()); + Long tenantId = number(authentication.getToken().getClaim("tenant_id")); + Long tokenVersion = number(authentication.getToken().getClaim("token_version")); + Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); + if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); + } + return client.validate(userId, tenantId, tokenVersion, permissionVersion) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("USER_DISABLED".equals(result.failureReason())) { + return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); + } + return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + return GatewaySecurityConfig.writeError(exchange, objectMapper, + "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + @Override public int getOrder() { return -95; } + + private static final class SecurityProjectionFailure extends RuntimeException { + private final String code; + private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java new file mode 100644 index 000000000..2ce22a244 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,8 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 200280692..3e7ce51f2 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -46,6 +46,10 @@ baiyue: auth-url: ${AUTH_SESSION_VALIDATION_URL} internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java new file mode 100644 index 000000000..ff9f935f0 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,83 @@ +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.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.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 OverseasSecurityProjectionFilterTest { + private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); + private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401"); + } + + @Test void disabledTenantAndStalePermissionFailWithA0403() { + for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, reason))); + var exchange = exchange("/api/merchant/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403"); + } + } + + @Test void projectionUnavailableFailsClosed() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(true, null))); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { + routed.set(true); return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); + verifyNoInteractions(isolatedClient); + } + + private static ServerWebExchange exchange(String path) { + Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); + return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, + List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md new file mode 100644 index 000000000..0acced4c7 --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,199 @@ +# Lastleg 内部模块化依赖基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 + +可重复命令: + +```powershell +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice +``` + +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 + +## 2. 当前规模与 SCC + +| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | +|---|---:|---:|---:|---:|---:|---:| +| main | 347 | 672 | 157 | 137 | 60 | 38 | +| test only | 98 | 0 | 67 | 0 | 0 | 0 | +| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 | + +主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: + +```text +dropship +inbound-pda +inventory +tailleg-handoff +``` + +合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC: + +```text +com.baiyue.lastleg.looseboard.service +com.baiyue.lastleg.relabel.service +com.baiyue.lastleg.service +com.baiyue.lastleg.transfer.service +``` + +这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。 + +## 3. 逻辑切片映射 + +| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 | +|---|---|---:|---:|---| +| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC | +| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 | +| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC | +| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 | +| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | +| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | +| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | + +另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 + +## 4. 主源码逻辑切片边 + +| 来源 | 目标 | 类边数 | +|---|---|---:| +| dispatch-delivery | inbound-pda | 6 | +| dispatch-delivery | inventory | 4 | +| dispatch-delivery | shared-technical | 2 | +| dispatch-delivery | tailleg-handoff | 8 | +| dropship | inbound-pda | 1 | +| dropship | shared-technical | 1 | +| dropship | tailleg-handoff | 2 | +| inbound-pda | dropship | 1 | +| inbound-pda | inventory | 25 | +| inbound-pda | shared-technical | 8 | +| inbound-pda | tailleg-handoff | 19 | +| inventory | inbound-pda | 1 | +| inventory | shared-technical | 2 | +| overseas-master-data | shared-technical | 3 | +| procurement-trucking-looseboard | overseas-master-data | 1 | +| procurement-trucking-looseboard | shared-technical | 5 | +| procurement-trucking-looseboard | tailleg-handoff | 5 | +| relabel-transfer-discard | dropship | 8 | +| relabel-transfer-discard | inventory | 6 | +| relabel-transfer-discard | shared-technical | 3 | +| relabel-transfer-discard | tailleg-handoff | 14 | +| tailleg-handoff | dropship | 2 | +| tailleg-handoff | inbound-pda | 6 | +| tailleg-handoff | inventory | 2 | +| tailleg-handoff | shared-technical | 2 | + +当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。 + +## 5. Entity/DTO/Mapper 泄漏基线 + +主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下: + +| 来源 → 目标 | 模型 | Mapper | 代表性证据 | +|---|---:|---:|---| +| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper | +| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper | +| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper | +| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper | +| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 | +| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 | +| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper | + +当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。 + +## 6. 首切片选择:车辆档案 + +选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图: + +1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。 +2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。 +3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。 +4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。 +5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。 +6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。 + +报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。 + +## 7. 已实施目标目录与精确文件 + +首切片已按下列结构实施: + +```text +masterdata/vehicle/ + adapter/in/web/ + PlatformVehicleController.java + application/ + VehicleApplicationService.java + model/VehicleModels.java + port/out/VehicleRepository.java + adapter/out/persistence/ + VehiclePersistenceAdapter.java + VehicleMapper.java + VehicleRecord.java +``` + +精确动作草案: + +| 原文件 | 已实施动作 | +|---|---| +| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 | +| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 | +| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port | +| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 | +| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 | +| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 | +| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record | + +允许依赖方向: + +```text +adapter.in.web -> application -> application.model + application.port.out +adapter.out.persistence -> application.port.out + application.model +``` + +Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。 + +## 8. 测试与回滚边界 + +本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新: + +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。 + +回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。 + +## 9. 当前只读合同 + +- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 + +最小验证: + +```powershell +node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +``` + +## 10. 首切片实施结果 + +- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。 +- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。 +- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。 +- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。 +- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。 +- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 + +首切片完成后停止,不进入第二切片。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index 03146b819..c639c1bcd 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -23,20 +23,22 @@ const sliceRules = [ ]; function parseArgs(argv) { - const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--source-set') options.sourceSet = argv[++index]; else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--graph') options.graph = argv[++index]; else if (argument === '--output') options.output = argv[++index]; else if (argument === '--check') options.check = argv[++index]; else if (argument === '--help') { - process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n'); process.exit(0); } else throw new Error(`Unknown argument: ${argument}`); } if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`); return options; } @@ -254,9 +256,14 @@ function renderMarkdown(report) { ].join('\n'); } -function renderDot(report) { - const lines = ['digraph lastleg_slices {', ' rankdir=LR;']; - for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); +function renderDot(report, graph) { + const edges = graph === 'package' ? report.packageEdges : report.sliceEdges; + const nodes = graph === 'package' + ? [...new Set(report.classes.map(entry => entry.packageName))].sort() + : [...new Set(report.classes.map(entry => entry.slice))].sort(); + const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;']; + for (const node of nodes) lines.push(` "${node}";`); + for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); lines.push('}', ''); return lines.join('\n'); } @@ -267,6 +274,30 @@ function checkContract(report, contractPath) { const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); @@ -290,6 +321,6 @@ const rendered = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : options.format === 'markdown' ? renderMarkdown(report) - : renderDot(report); + : renderDot(report, options.graph); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs new file mode 100644 index 000000000..bb8a6a945 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); +const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; + +function analyze(sourceSet = 'main') { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the Lastleg baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('main, test, and combined source sets expose package dependency graphs', () => { + for (const sourceSet of ['main', 'test', 'all']) { + const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], { + cwd: moduleRoot, + encoding: 'utf8', + }); + assert.match(dot, /^digraph lastleg_packages \{/); + assert.match(dot, /com\.baiyue\.lastleg/); + } +}); + +test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix)); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord', + 'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService', + 'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels', + 'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository', + ]); + const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix) + && !edge.target.startsWith(vehiclePrefix)); + const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix) + && !edge.source.startsWith(vehiclePrefix)); + assert.deepEqual(outgoing, [{ + source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + target: 'com.baiyue.lastleg.security.OverseasPermissions', + }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); + assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); + assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); +}); + +test('vehicle application only depends inward and stays free of adapter frameworks', () => { + const report = analyze(); + const prefix = `${vehiclePrefix}.application`; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java index 27a8b5cf0..298124a59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -10,12 +10,19 @@ import java.net.http.HttpClient; public class LastlegRestClientFactory { public RestClient build(RestClient.Builder builder, String baseUrl, LastlegRestClientProperties.ClientTimeouts timeouts) { - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(timeouts.getConnectTimeout()) - .build(); - JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); - requestFactory.setReadTimeout(timeouts.getReadTimeout()); + JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts); return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); } -} + HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) { + return HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + } + + JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts)); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return requestFactory; + } +} 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 28c46ba7a..7fd3375d5 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 @@ -50,8 +50,14 @@ public class LastlegOrderController { @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType()) - ? shippingNodeConsumer.consume(event) : consumer.consume(event); + int consumed; + if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { + consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { + consumed = consumer.consume(event); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + } return ApiResponse.success(consumed); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java new file mode 100644 index 000000000..f2d570a9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") +@Tag(name = "Vehicle master data / 车辆主数据") +public class PlatformVehicleController { + private final VehicleApplicationService service; + + public PlatformVehicleController(VehicleApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") + public ApiResponse> page(@Valid VehicleModels.Query query) { + return execute(() -> ApiResponse.success(service.page(query))); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") + public ApiResponse detail(@PathVariable long id) { + return execute(() -> ApiResponse.success(service.detail(id))); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody VehicleModels.Create request) { + return execute(() -> ApiResponse.success(service.create(operator(user), request))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.Update request) { + return execute(() -> ApiResponse.success(service.update(id, operator(user), request))); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.ChangeStatus request) { + return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request))); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") + public ResponseEntity export(@Valid VehicleModels.Query query) { + return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query))); + } + + private String operator(AuthenticatedUser user) { + if (user == null) return "UNKNOWN"; + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private T execute(Supplier action) { + try { + return action.get(); + } catch (VehicleFailure failure) { + HttpStatus status = switch (failure.kind()) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + }; + throw new ResponseStatusException(status, failure.getMessage(), failure); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java similarity index 55% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java index 71198fc52..10a96df7f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java @@ -1,13 +1,12 @@ -package com.baiyue.lastleg.masterdata.vehicle.mapper; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @Mapper -public interface VehicleMapper extends BaseMapper { +public interface VehicleMapper extends BaseMapper { @Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE") - Vehicle lockById(@Param("id") long id); + VehicleRecord lockById(@Param("id") long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java new file mode 100644 index 000000000..cb5252cf7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +@Repository +public class VehiclePersistenceAdapter implements VehicleRepository { + private final VehicleMapper vehicles; + + public VehiclePersistenceAdapter(VehicleMapper vehicles) { + this.vehicles = vehicles; + } + + @Override + public PageSlice page(SearchCriteria criteria) { + Page page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria)); + return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + @Override + public List list(SearchCriteria criteria) { + return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot); + } + + @Override + public Optional lockById(long id) { + return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot); + } + + @Override + public long create(NewVehicle vehicle) { + VehicleRecord record = new VehicleRecord(); + record.setId(IdWorker.getId()); + record.setPlateNumber(vehicle.plateNumber()); + record.setModel(vehicle.model()); + record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); + record.setMileageKm(vehicle.mileageKm()); + record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); + record.setPhone(vehicle.phone()); + record.setRegion(vehicle.region()); + record.setOwnership(vehicle.ownership()); + record.setStatus(vehicle.status()); + record.setRemark(vehicle.remark()); + record.setRecordVersion(0L); + record.setCreatedBy(vehicle.operator()); + record.setUpdatedBy(vehicle.operator()); + record.setCreatedAt(vehicle.createdAt()); + record.setUpdatedAt(vehicle.createdAt()); + try { + vehicles.insert(record); + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + return record.getId(); + } + + @Override + public boolean update(long id, long expectedVersion, VehicleChanges changes) { + try { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getPlateNumber, changes.plateNumber()) + .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) + .set(VehicleRecord::getMileageKm, changes.mileageKm()) + .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) + .set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region()) + .set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark()) + .set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt()) + .setSql("record_version = record_version + 1")) == 1; + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + } + + @Override + public boolean updateStatus(long id, long expectedVersion, String status, String operator, + java.time.LocalDateTime updatedAt) { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator) + .set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1; + } + + private LambdaQueryWrapper wrapper(SearchCriteria criteria) { + return new LambdaQueryWrapper() + .and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword()) + .or().like(VehicleRecord::getModel, criteria.keyword()) + .or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword())) + .eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership()) + .eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region()) + .eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status()) + .orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId); + } + + private VehicleSnapshot snapshot(VehicleRecord value) { + return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), + value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), + value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), + value.getUpdatedBy(), value.getUpdatedAt()); + } + + private static long version(Long value) { + return value == null ? 0L : value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java similarity index 88% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 82a303ac5..9518e94e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.entity; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; @@ -10,7 +10,7 @@ import java.time.LocalDateTime; @Data @TableName("ll_vehicle") -public class Vehicle { +public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java new file mode 100644 index 000000000..79fbb24d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +@Service +public class VehicleApplicationService { + private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); + private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); + private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); + + private final VehicleRepository vehicles; + + public VehicleApplicationService(VehicleRepository vehicles) { + this.vehicles = vehicles; + } + + public PageResult page(VehicleModels.Query query) { + var page = vehicles.page(criteria(query)); + return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(), + page.size(), page.total(), page.pages()); + } + + public VehicleModels.Response detail(long id) { + return response(required(id)); + } + + @Transactional + public VehicleModels.Response create(String operator, VehicleModels.Create request) { + LocalDateTime now = LocalDateTime.now(); + long id; + try { + id = vehicles.create(new NewVehicle( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + return detail(id); + } + + @Transactional + public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + boolean updated; + try { + updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + @Transactional + public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + String status = status(request.status()); + if (status.equals(current.status())) return response(current); + boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + public byte[] exportCsv(VehicleModels.Query query) { + var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); + StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); + for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') + .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') + .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(row.updatedAt()).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private SearchCriteria criteria(VehicleModels.Query query) { + return new SearchCriteria(query.page(), query.size(), trim(query.keyword()), + optional(query.ownership(), OWNERSHIPS, "车辆归属"), + optional(query.region(), REGIONS, "车辆区域"), + optional(query.status(), STATUSES, "车辆状态")); + } + + private VehicleSnapshot required(long id) { + return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleSnapshot locked(long id) { + return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleModels.Response response(VehicleSnapshot value) { + return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), + value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), + value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), + value.updatedBy(), value.updatedAt()); + } + + private void requireVersion(VehicleSnapshot value, long expected) { + if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + } + + private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } + private static String region(String value) { return required(value, REGIONS, "车辆区域"); } + private static String status(String value) { return required(value, STATUSES, "车辆状态"); } + + private static String optional(String value, Set allowed, String label) { + return StringUtils.hasText(value) ? required(value, allowed, label) : null; + } + + private static String required(String value, Set allowed, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); + return normalized; + } + + private static String requiredText(String value, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + return value.trim(); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); } + private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); } + private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); } + + public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT } + + public static final class VehicleFailure extends RuntimeException { + private final FailureKind kind; + + public VehicleFailure(FailureKind kind, String message) { + super(message); + this.kind = kind; + } + + public FailureKind kind() { + return kind; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java similarity index 94% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 4c19ceb3e..36a307164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.dto; +package com.baiyue.lastleg.masterdata.vehicle.application.model; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Digits; @@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDateTime; -public final class VehicleDtos { - private VehicleDtos() { } +public final class VehicleModels { + private VehicleModels() { } public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, String ownership, String region, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java new file mode 100644 index 000000000..31f0b89a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface VehicleRepository { + PageSlice page(SearchCriteria criteria); + + List list(SearchCriteria criteria); + + Optional findById(long id); + + Optional lockById(long id); + + long create(NewVehicle vehicle); + + boolean update(long id, long expectedVersion, VehicleChanges changes); + + boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt); + + record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { } + + record PageSlice(List records, long page, long size, long total, long pages) { } + + record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String status, + String remark, String operator, LocalDateTime createdAt) { } + + record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String remark, + String operator, LocalDateTime updatedAt) { } + + record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, + long mileageKm, String driverOrDispatchCompany, String phone, String region, + String ownership, String status, String remark, long recordVersion, + String updatedBy, LocalDateTime updatedAt) { } + + final class DuplicatePlateException extends RuntimeException { + public DuplicatePlateException(Throwable cause) { + super(cause); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java deleted file mode 100644 index 67dd55ff4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.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.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService; -import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.nio.charset.StandardCharsets; - -@RestController -@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") -@Tag(name = "Vehicle master data / 车辆主数据") -public class PlatformVehicleController { - @Resource private VehicleService service; - - @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") - public ApiResponse> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); } - - @GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") - public ApiResponse detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); } - - @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); } - - @PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") - public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); } - - @PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") - public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); } - - @GetMapping(value = "/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") - public ResponseEntity export(@Valid VehicleDtos.Query query) { - return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)); - } - - private String operator(AuthenticatedUser user) { - if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java deleted file mode 100644 index 191cae17b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Locale; -import java.util.Set; - -@Service -public class VehicleService { - private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); - private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); - private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); - @Resource private VehicleMapper vehicles; - - public PageResult page(VehicleDtos.Query query) { - Page page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query)); - return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), - page.getSize(), page.getTotal(), page.getPages()); - } - - public VehicleDtos.Response detail(long id) { return response(required(id)); } - - @Transactional - public VehicleDtos.Response create(String operator, VehicleDtos.Create request) { - LocalDateTime now = LocalDateTime.now(); - Vehicle value = new Vehicle(); value.setId(IdWorker.getId()); - value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)); - value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km()); - value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany())); - value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership())); - value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L); - value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now); - try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - return detail(value.getId()); - } - - @Transactional - public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码"); - int updated; - try { - updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) - .set(Vehicle::getModel, requiredText(request.model(), "车型")) - .set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km()) - .set(Vehicle::getMileageKm, request.mileageKm()) - .set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany())) - .set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region())) - .set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark())) - .set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now()) - .setSql("record_version = record_version + 1")); - } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - @Transactional - public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - String status = status(request.status()); - if (status.equals(current.getStatus())) return response(current); - int updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator) - .set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1")); - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - public byte[] exportCsv(VehicleDtos.Query query) { - var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') - .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') - .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') - .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') - .append(row.updatedAt()).append("\r\n"); - return csv.toString().getBytes(StandardCharsets.UTF_8); - } - - private LambdaQueryWrapper wrapper(VehicleDtos.Query query) { - String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属"); - String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态"); - return new LambdaQueryWrapper() - .and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword) - .or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword)) - .eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region) - .eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId); - } - private Vehicle required(long id) { - Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value; - } - private Vehicle locked(long id) { - Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value; - } - private VehicleDtos.Response response(Vehicle value) { - return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(), - value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), - value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), - value.getUpdatedBy(), value.getUpdatedAt()); - } - private void requireVersion(Vehicle value, long expected) { - if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - } - private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } - private static String region(String value) { return required(value, REGIONS, "车辆区域"); } - private static String status(String value) { return required(value, STATUSES, "车辆状态"); } - private static String optional(String value, Set allowed, String label) { - return StringUtils.hasText(value) ? required(value, allowed, label) : null; - } - private static String required(String value, Set allowed, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); - String normalized = value.trim().toUpperCase(Locale.ROOT); - if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized; - } - private static String requiredText(String value, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim(); - } - private static long version(Long value) { return value == null ? 0L : value; } - private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } - private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); } - private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 72fd13cd9..53e110c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) .set(LastlegEventOutbox::getStatus, "PROCESSING") 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 0896e5b77..fd8c1383f 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 @@ -54,6 +54,8 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); @@ -70,6 +72,11 @@ public class LoadingCompletedConsumer { inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); + if (raced == null || raced.getPayloadHash() == null + || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("事件摘要冲突", exception); + } return countOrders(envelope,payload.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 index 938bc4404..b641c1e46 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -91,6 +93,10 @@ public class ContainerShippingNodeConsumer { private void validate(EventEnvelope envelope) { envelope.requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java new file mode 100644 index 000000000..94c1758ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.config; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegRestClientFactoryTest { + private final LastlegRestClientFactory factory = new LastlegRestClientFactory(); + + @Test + void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() { + LastlegRestClientProperties properties = new LastlegRestClientProperties(); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void rejectsUnboundedOrNonPositiveTimeouts() { + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void bindsPerClientTimeoutOverridesFromConfiguration() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.http.clients.unload-photo-file.connect-timeout", "125ms", + "baiyue.http.clients.unload-photo-file.read-timeout", "9s", + "baiyue.http.clients.pallet-physical.connect-timeout", "250ms", + "baiyue.http.clients.pallet-physical.read-timeout", "2s")); + + LastlegRestClientProperties properties = new Binder(source) + .bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class)) + .orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound")); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception { + var timeouts = new LastlegRestClientProperties.ClientTimeouts( + Duration.ofMillis(250), Duration.ofMillis(75)); + assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250)); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow", exchange -> { + try { Thread.sleep(500); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + exchange.sendResponseHeaders(200, 0); + exchange.close(); + }); + server.start(); + try { + RestClient client = factory.build(RestClient.builder(), + "http://127.0.0.1:" + server.getAddress().getPort(), timeouts); + assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity()) + .isInstanceOf(ResourceAccessException.class); + } finally { + server.stop(0); + } + } +} 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 aeee3ebe5..abb7991c9 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 @@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; @@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); @@ -46,6 +50,17 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { + EventEnvelope event = mock(EventEnvelope.class); + when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); + + assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + + verify(loading, never()).consume(event); + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java similarity index 96% rename from lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java rename to lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java index 719dcf8e3..ae1a8d3e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.controller; +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java new file mode 100644 index 000000000..91fffa32d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VehicleApplicationServiceTest { + private VehicleRepository repository; + private VehicleApplicationService service; + + @BeforeEach + void setUp() { + repository = mock(VehicleRepository.class); + service = new VehicleApplicationService(repository); + } + + @Test + void rejectsStaleVersionBeforeUpdating() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(2L, 1000L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT)); + } + + @Test + void rejectsDecreasingOdometer() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(3L, 999L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + @Test + void rejectsUnknownStatus() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L))) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + private static VehicleModels.Update update(long version, long mileage) { + return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", null); + } + + private static VehicleSnapshot vehicle(long version, long mileage) { + return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java deleted file mode 100644 index b83562614..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class VehicleServiceTest { - private VehicleMapper mapper; - private VehicleService service; - - @BeforeEach - void setUp() { - mapper = mock(VehicleMapper.class); - service = new VehicleService(); - ReflectionTestUtils.setField(service, "vehicles", mapper); - } - - @Test - void rejectsStaleVersionBeforeUpdating() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(2L, 1000L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("409 CONFLICT"); - } - - @Test - void rejectsDecreasingOdometer() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(3L, 999L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - @Test - void rejectsUnknownStatus() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - private static VehicleDtos.Update update(long version, long mileage) { - return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", null); - } - - private static Vehicle vehicle(long version, long mileage) { - Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage); - value.setStatus("IN_SERVICE"); return value; - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..b2123ef9e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UnloadCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + LastlegEventOutbox.class); + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "status", "locked_at"); + } +} 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 23e55decb..c7ee14c86 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 @@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest { @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,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 tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } + @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql")); 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 index 5e1b9c95d..30bec281f 100644 --- 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 @@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest { .hasMessageContaining("摘要冲突"); } + @Test + void tamperedPayloadHashIsRejectedBeforeInboxMutation() { + EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); + verify(inboxes, never()).insert(any(LastlegEventInbox.class)); + } + 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"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json new file mode 100644 index 000000000..cc5660ecb --- /dev/null +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "dispatch-delivery->inbound-pda", + "dispatch-delivery->inventory", + "dispatch-delivery->shared-technical", + "dispatch-delivery->tailleg-handoff", + "dropship->inbound-pda", + "dropship->shared-technical", + "dropship->tailleg-handoff", + "inbound-pda->dropship", + "inbound-pda->inventory", + "inbound-pda->shared-technical", + "inbound-pda->tailleg-handoff", + "inventory->inbound-pda", + "inventory->shared-technical", + "overseas-master-data->shared-technical", + "procurement-trucking-looseboard->overseas-master-data", + "procurement-trucking-looseboard->shared-technical", + "procurement-trucking-looseboard->tailleg-handoff", + "relabel-transfer-discard->dropship", + "relabel-transfer-discard->inventory", + "relabel-transfer-discard->shared-technical", + "relabel-transfer-discard->tailleg-handoff", + "tailleg-handoff->dropship", + "tailleg-handoff->inbound-pda", + "tailleg-handoff->inventory", + "tailleg-handoff->shared-technical" + ], + "requiredClassLocations": { + "com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", + "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", + "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus", + "com.baiyue.lastleg.client", + "com.baiyue.lastleg.config" + ] + }, + "sliceSpecificRules": { + "overseas-master-data": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": ["procurement-trucking-looseboard"] + } + } +} diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md new file mode 100644 index 000000000..01cb5953f --- /dev/null +++ b/overseas-security-service/AGENTS.md @@ -0,0 +1,14 @@ +# Overseas Security Service 模块指引 + +## 职责边界 + +- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 +- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 +- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 + +## 入口 + +- 内部事件与校验:`controller/OverseasSecurityController.java` +- 幂等、单调投影:`service/SecurityProjectionService.java` +- Token 校验:`service/OverseasSecurityValidationService.java` +- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml new file mode 100644 index 000000000..a8c5cd7fd --- /dev/null +++ b/overseas-security-service/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + overseas-security-service + + com.baiyue.sharedbaiyue-common-core${project.version} + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedcommon-event-contract${project.version} + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-data-redis + com.mysqlmysql-connector-jruntime + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java new file mode 100644 index 000000000..cfe7c82a6 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java @@ -0,0 +1,11 @@ +package com.baiyue.overseassecurity; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OverseasSecurityApplication { + public static void main(String[] args) { + SpringApplication.run(OverseasSecurityApplication.class, args); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java new file mode 100644 index 000000000..05fe60b65 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java @@ -0,0 +1,52 @@ +package com.baiyue.overseassecurity.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; +import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; +import com.baiyue.overseassecurity.service.SecurityProjectionService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +@Hidden +@RestController +@RequestMapping("/internal/overseas-security/v1") +public class OverseasSecurityController { + private final InternalServiceTokenGuard tokenGuard; + private final SecurityProjectionService projections; + private final OverseasSecurityValidationService validations; + + public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, + OverseasSecurityValidationService validations) { + this.tokenGuard = tokenGuard; + this.projections = projections; + this.validations = validations; + } + + @PostMapping("/events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope envelope) { + tokenGuard.verify(token); + var result = projections.consume(envelope); + return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); + } + + @PostMapping("/validate") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody ValidationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(validations.validate(request)); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java new file mode 100644 index 000000000..7560da9b2 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public final class OverseasSecurityDtos { + private OverseasSecurityDtos() { } + + public record Acknowledgement(String eventId, String result, Instant receivedAt) { } + + public record ValidationRequest(@NotNull @Min(1) Long userId, + @NotNull @Min(0) Long tenantId, + @NotNull @Min(0) Long tokenVersion, + @NotNull @Min(0) Long permissionVersion) { } + + public record ValidationResponse(boolean valid, FailureReason failureReason) { + public static ValidationResponse accepted() { return new ValidationResponse(true, null); } + public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + } + + public enum FailureReason { + USER_DISABLED, + TENANT_DISABLED, + PERMISSION_VERSION_STALE + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..1a6aa6b7c --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.overseassecurity.security; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + @Value("${baiyue.security.internal-service-token:}") private String configuredToken; + private byte[] expected; + + @PostConstruct void initialize() { + expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + + public void verify(String supplied) { + byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java new file mode 100644 index 000000000..4b7ee66cf --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java @@ -0,0 +1,44 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class OverseasSecurityValidationService { + private final JdbcTemplate jdbc; + + public OverseasSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId())); + if (userStatus != null && !"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); + if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + Long projectedVersion = jdbc.queryForObject( + "SELECT MAX(permission_version) FROM overseas_security_projection " + + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", + Long.class, String.valueOf(request.userId())); + if (projectedVersion != null && request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id) { + List values = jdbc.query("SELECT security_status FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=?", + (rs, row) -> rs.getString(1), type, id); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java new file mode 100644 index 000000000..958e5e44b --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java @@ -0,0 +1,19 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Objects; + +final class ProjectionMonotonicity { + private ProjectionMonotonicity() { } + + static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { + if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; + if (incomingVersion < currentVersion) return Decision.STALE; + if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; + throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); + } + + enum Decision { APPLY, STALE, DUPLICATE_VERSION } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java new file mode 100644 index 000000000..e022e01cb --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ +@Component +@Slf4j +public class SecurityProjectionRedisMirror { + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") + private String keyPrefix; + + public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void mirror(SecurityProjectionService.ProjectionChanged event) { + var value = event.projection(); + try { + redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), + objectMapper.writeValueAsString(value)); + } catch (Exception exception) { + log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", + value.aggregateType(), value.aggregateId()); + } + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java new file mode 100644 index 000000000..d76d77932 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java @@ -0,0 +1,153 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Service +public class SecurityProjectionService { + private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher events; + + public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.events = events; + } + + @Transactional + public ConsumeResult consume(EventEnvelope envelope) { + envelope.requireValid(); + requireSecurityEnvelope(envelope); + String payloadJson = envelope.payload().toString(); + try { + jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), + envelope.payloadHash(), payloadJson); + } catch (DuplicateKeyException duplicate) { + InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + + "WHERE event_id=? FOR UPDATE", + (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), + envelope.eventId()); + if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) + || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); + } + if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); + } + + ProjectionInput input = input(envelope); + ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); + ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), + input.aggregateVersion(), input.payloadHash()); + String result = decision.name(); + if (decision == ProjectionMonotonicity.Decision.APPLY) { + upsert(input); + result = "APPLIED"; + events.publishEvent(new ProjectionChanged(input)); + } + jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + + "WHERE event_id=?", result, envelope.eventId()); + return new ConsumeResult(result, input); + } + + private void requireSecurityEnvelope(EventEnvelope envelope) { + if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); + } + } + + private ProjectionInput input(EventEnvelope envelope) { + try { + return switch (envelope.eventType()) { + case EventTypes.IAM_USER_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), + value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + case EventTypes.IAM_TENANT_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), + value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), + value.changedAt()); + } + case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), + null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + default -> throw new IllegalArgumentException("unsupported type"); + }; + } catch (BizException exception) { + throw exception; + } catch (Exception exception) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); + } + } + + private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + if (!aggregateType.equals(envelope.aggregateType()) + || !String.valueOf(aggregateId).equals(envelope.aggregateId()) + || !String.valueOf(tenantId).equals(envelope.tenantId())) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); + } + } + + private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); + } + } + + private ProjectionRow findForUpdate(String type, String id) { + List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", + (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); + return rows.isEmpty() ? null : rows.get(0); + } + + private void upsert(ProjectionInput value) { + jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), + value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + } + + private record InboxRow(String eventType, String payloadHash, String status) { } + private record ProjectionRow(long aggregateVersion, String payloadHash) { } + public record ConsumeResult(String result, ProjectionInput projection) { } + public record ProjectionChanged(ProjectionInput projection) { } + public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, + Long permissionVersion, long aggregateVersion, String payloadHash, + String eventId, Instant changedAt) { } +} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml new file mode 100644 index 000000000..6817568d2 --- /dev/null +++ b/overseas-security-service/src/main/resources/application.yml @@ -0,0 +1,28 @@ +server: + port: ${OVERSEAS_SECURITY_PORT:9307} +spring: + application.name: overseas-security-service + config.import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${OVERSEAS_SECURITY_DB_URL} + username: ${OVERSEAS_SECURITY_DB_USERNAME} + password: ${OVERSEAS_SECURITY_DB_PASSWORD} + data.redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + cloud.nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} +baiyue.security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql new file mode 100644 index 000000000..7553fba93 --- /dev/null +++ b/overseas-security-service/src/main/resources/db/overseas-security.sql @@ -0,0 +1,31 @@ +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; + +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件Payload', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', + result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL, + processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type, received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; + +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type, aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id, permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml new file mode 100644 index 000000000..e228e9386 --- /dev/null +++ b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Overseas Security Projection Internal API + version: 1.0.0 +paths: + /internal/overseas-security/v1/events: + post: + summary: Consume one IAM security EventEnvelope + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "Acknowledgement contains eventId, result and receivedAt" + /internal/overseas-security/v1/validate: + post: + summary: Validate locally verified overseas JWT claim versions + description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java new file mode 100644 index 000000000..b626bb89b --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.overseassecurity; + +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 OverseasSecurityContractTest { + @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { + String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); + String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); + String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); + String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); + assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); + assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + } + + @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { + String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); + String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); + String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); + assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); + assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") + .doesNotContain("IamSecurityEventsV1"); + assertThat(lastleg).contains("Lastleg 不消费该事件类型") + .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); + } + + @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { + String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); + assertThat(openapi).contains("/internal/overseas-security/v1/events:", + "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", + "userId, tenantId, tokenVersion and permissionVersion"); + assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); + } + + 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/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java new file mode 100644 index 000000000..f38834f99 --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); + + @Test void disabledUserAndTenantFailClosed() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of("DISABLED")); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of(), List.of()); + when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); + } +} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java new file mode 100644 index 000000000..89560963c --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.STALE); + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/pom.xml b/pom.xml index 0e706d52c..2cd812fb5 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ common-event-contract gateway gateway-overseas + overseas-security-service auth iam order @@ -66,6 +67,7 @@ 3.13.0 3.4.1 0.8.12 + 4.9.8.5 1.5.0 2.3.3 @@ -168,4 +170,31 @@ + + + static-analysis-report + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + true + true + false + true + + + + 生成静态分析报告 + verify + spotbugs + + + + + + + diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 371e7053f..6c094c6e3 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 23) + assert.equal(new Set(modules).size, 23) + assert.equal(services.size, 16) const bootModules = [] for (const module of modules) { @@ -155,6 +155,32 @@ test('Maven verify publishes JaCoCo reports without enforcing a threshold', asyn assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) }) +test('SpotBugs remains an opt-in non-blocking report without quality thresholds', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const profile = pom.match(/\s*static-analysis-report<\/id>[\s\S]*?<\/profile>/)?.[0] ?? '' + const spotbugs = profile.match(/\s*com\.github\.spotbugs<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + const reportJob = gitlab.match(/^静态分析报告:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const verifyJob = gitlab.match(/^校验与测试:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(pom, /4\.9\.8\.5<\/spotbugs-maven-plugin\.version>/) + assert.match(spotbugs, /spotbugs-maven-plugin<\/artifactId>/) + assert.match(spotbugs, /verify<\/phase>/) + assert.match(spotbugs, /spotbugs<\/goal>/) + assert.match(spotbugs, /true<\/xmlOutput>/) + assert.match(spotbugs, /true<\/htmlOutput>/) + assert.match(spotbugs, /false<\/failOnError>/) + assert.doesNotMatch(spotbugs, /check<\/goal>||/) + assert.doesNotMatch(pom.replace(profile, ''), /spotbugs-maven-plugin<\/artifactId>/) + assert.match(reportJob, /^ allow_failure: true$/m) + assert.match(reportJob, /-Pstatic-analysis-report/) + assert.match(reportJob, /^ - "\*\*\/target\/spotbugs\*\.xml"$/m) + assert.match(reportJob, /^ - "\*\*\/target\/site\/spotbugs\.html"$/m) + assert.doesNotMatch(verifyJob, /static-analysis-report|spotbugs/i) + assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) +}) + test('regional Compose and Nacos manifests cover the service-map topology', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) const composeByRegion = new Map([ diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 82c4591fd..b1f89decb 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,16 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) + assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -30,10 +32,12 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -46,6 +50,7 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -53,6 +58,8 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) + assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index e4c7ced35..baa57b467 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..0e829bb6f --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,230 @@ +export const CONTEXTS = Object.freeze(['REQUEST', 'BACKGROUND', 'INFRASTRUCTURE', 'EXTERNAL']) +export const ACCESS_MODES = Object.freeze(['READ', 'WRITE', 'READ_WRITE']) +export const IDEMPOTENCY = Object.freeze([ + 'SAFE_READ', 'IDEMPOTENCY_KEY', 'BUSINESS_KEY', 'EVENT_ID', 'STATE_CONVERGENT', 'NONE', 'MIXED' +]) + +const feign = (path, source, target, contexts, access, idempotency, note) => ({ + id: `FEIGN:${path}`, + transport: 'FEIGN', path, source, target, contexts, access, idempotency, note +}) + +const http = (path, source, target, contexts, access, idempotency, note) => ({ + id: `HTTP:${path}`, + transport: 'HTTP', path, source, target, contexts, access, idempotency, note +}) + +export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), + feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '注册/补偿以 authUserId 收敛,不自动重试'), + + feign('file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java', + 'file-service', 'finance-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java', + 'file-service', 'firstleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java', + 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', + 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', + 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', + 'file-service', 'warehouse-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WeightOcrClient.java', + 'file-service', 'integration-service', ['REQUEST'], 'READ', 'NONE', '上传线程内调用 OCR;第三方调用不可假定免费幂等'), + + feign('finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '元数据 GET 可重复,凭证上传无幂等键'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java', + 'finance-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取整柜成本分摊快照'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java', + 'finance-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'MIXED', + '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), + feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', + 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), + feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', + 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), + + feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', + 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', + 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', + 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户名称与状态快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据/下载可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取装柜证据元数据/内容'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '价目文件上传无幂等键,读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库/渠道主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java', + 'firstleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '无状态 PDF 渲染,POST 为计算型读取'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '附件上传无幂等键,元数据读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '查询可重复;预占/选择释放/转移/结算使用业务幂等键,普通确认/释放按状态收敛'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取供应商快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '报关文件上传无幂等键,读取可重复'), + + feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', + 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), + feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', + 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', + 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), + + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', + 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + + feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY', + '列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 校验计价渠道,不写履约状态'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 查询统一订单投影'), + feign('order/src/main/java/com/baiyue/order/client/ProductImageClient.java', + 'order-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取商品图片元数据'), + feign('order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java', + 'order-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户下单资格'), + feign('order/src/main/java/com/baiyue/order/client/WarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + + http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', + 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', + 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service', 'external-evergreen', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service', 'external-hede', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service', 'external-matson', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service', 'external-sml', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service', 'external-zim', ['EXTERNAL'], 'READ', 'SAFE_READ', '令牌获取与轨迹查询不改变平台业务事实'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java', + 'integration-service', 'external-baidu-ocr', ['EXTERNAL'], 'READ', 'NONE', 'OCR 可能计费,不由 HTTP 层自动重试') +]) + +export const USER_ENTRIES = Object.freeze([ + { name: '认证 API', service: 'auth-service' }, + { name: 'IAM API', service: 'iam-service' }, + { name: '订单 API', service: 'order-service' }, + { name: '头程 API/PDA', service: 'firstleg-service' }, + { name: '财务 API', service: 'finance-service' }, + { name: '文件 API', service: 'file-service' }, + { name: '海外尾程 API/PDA', service: 'lastleg-service' }, + { name: '国内仓储 API', service: 'warehouse-service' }, + { name: '海外仓储 API', service: 'warehouse-overseas-service' } +]) + +export function graphEdges(context) { + return [...new Set(SYNCHRONOUS_CLIENTS + .filter(client => context === 'ALL' || client.contexts.includes(context)) + .map(client => `${client.source}->${client.target}`))].sort() +} + +export function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +export function longestSimplePaths(entryService, edgeNames = graphEdges('REQUEST')) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + adjacency.get(from).add(to) + } + let longest = [] + function visit(node, path) { + const targets = [...(adjacency.get(node) ?? [])].sort() + let extended = false + for (const target of targets) { + if (path.includes(target)) continue + extended = true + visit(target, [...path, target]) + } + if (extended) return + if (longest.length === 0 || path.length > longest[0].length) longest = [path] + else if (path.length === longest[0].length) longest.push(path) + } + visit(entryService, [entryService]) + return longest.sort((left, right) => left.join('->').localeCompare(right.join('->'))) +} diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 60c2af78b..a36533f8c 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -3,72 +3,62 @@ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { + ACCESS_MODES, CONTEXTS, IDEMPOTENCY, SYNCHRONOUS_CLIENTS, USER_ENTRIES, + graphEdges, longestSimplePaths, stronglyConnectedComponents +} from './synchronous-dependency-baseline.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' + 'warehouse-overseas-service', 'overseas-security-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) -const expectedFeignEdges = new Map(Object.entries({ - 'auth-service->iam-service': 2, - 'file-service->finance-service': 1, - 'file-service->firstleg-service': 1, - 'file-service->iam-service': 2, - 'file-service->integration-service': 1, - 'file-service->order-service': 1, - 'file-service->warehouse-service': 1, - 'finance-service->file-service': 1, - 'finance-service->firstleg-service': 2, - 'finance-service->iam-service': 1, - 'finance-service->warehouse-service': 1, - 'firstleg-service->event-bridge-service': 1, - 'firstleg-service->file-service': 7, - 'firstleg-service->finance-service': 1, - 'firstleg-service->iam-service': 1, - 'firstleg-service->integration-service': 1, - 'firstleg-service->order-service': 2, - 'firstleg-service->print-service': 1, - 'firstleg-service->warehouse-service': 4, - 'iam-service->auth-service': 1, - 'iam-service->event-bridge-service': 1, - 'iam-service->firstleg-service': 1, - 'iam-service->warehouse-service': 1, - 'lastleg-service->event-bridge-service': 1, - 'lastleg-service->warehouse-service': 1, - 'order-service->file-service': 1, - 'order-service->firstleg-service': 3, - 'order-service->iam-service': 1, - 'order-service->warehouse-service': 1 -})) - -const expectedRawHttpImplementations = [ - 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java' +const expectedOnlineEdges = [ + 'auth-service->iam-service', + 'file-service->iam-service', + 'file-service->integration-service', + 'finance-service->file-service', + 'finance-service->firstleg-service', + 'finance-service->iam-service', + 'finance-service->warehouse-service', + 'firstleg-service->file-service', + 'firstleg-service->iam-service', + 'firstleg-service->order-service', + 'firstleg-service->print-service', + 'firstleg-service->warehouse-service', + 'iam-service->auth-service', + 'iam-service->firstleg-service', + 'iam-service->warehouse-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service' ] -const reviewedRawInternalEdges = [ +const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'gateway-overseas->auth-service', - 'lastleg-service->file-service', - 'lastleg-service->warehouse-overseas-service' + 'event-bridge-service->overseas-security-service', + 'file-service->finance-service', + 'file-service->firstleg-service', + 'file-service->iam-service', + 'file-service->order-service', + 'file-service->warehouse-service', + 'finance-service->firstleg-service', + 'firstleg-service->event-bridge-service', + 'firstleg-service->finance-service', + 'firstleg-service->integration-service', + 'iam-service->event-bridge-service', + 'lastleg-service->event-bridge-service', + 'order-service->firstleg-service' ] const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -87,7 +77,7 @@ async function javaFiles(directory) { } async function discoverFeignEdges() { - const edges = new Map() + const clients = [] for (const module of serviceModules) { const sourceRoot = path.join(root, module, 'src', 'main', 'java') for (const file of await javaFiles(sourceRoot)) { @@ -96,66 +86,24 @@ async function discoverFeignEdges() { const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) - const edge = `${canonicalSource.get(module)}->${target}` - edges.set(edge, (edges.get(edge) ?? 0) + 1) + clients.push({ + path: path.relative(root, file).replaceAll('\\', '/'), + source: canonicalSource.get(module), target + }) } } - return edges + return clients.sort((left, right) => left.path.localeCompare(right.path)) } -function sortedEntries(map) { - return [...map.entries()].sort(([left], [right]) => left.localeCompare(right)) -} - -function stronglyConnectedComponents(edgeNames) { - const adjacency = new Map() - for (const edge of edgeNames) { - const [from, to] = edge.split('->') - if (!adjacency.has(from)) adjacency.set(from, new Set()) - if (!adjacency.has(to)) adjacency.set(to, new Set()) - adjacency.get(from).add(to) - } - let index = 0 - const indexes = new Map() - const lowLinks = new Map() - const stack = [] - const onStack = new Set() - const components = [] - - function connect(node) { - indexes.set(node, index) - lowLinks.set(node, index++) - stack.push(node) - onStack.add(node) - for (const target of adjacency.get(node)) { - if (!indexes.has(target)) { - connect(target) - lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) - } else if (onStack.has(target)) { - lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) - } - } - if (lowLinks.get(node) !== indexes.get(node)) return - const component = [] - let current - do { - current = stack.pop() - onStack.delete(current) - component.push(current) - } while (current !== node) - components.push(component.sort()) - } - - for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) - return components.filter(component => component.length > 1) - .sort((left, right) => left.join().localeCompare(right.join())) -} - -test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => { - assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges)) +test('all 44 Feign clients have reviewed context, access and idempotency metadata', async () => { + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') + .map(({ path, source, target }) => ({ path, source, target })) + .sort((left, right) => left.path.localeCompare(right.path)) + assert.equal(reviewed.length, 44) + assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('raw HTTP transports cannot be added without an explicit graph review', async () => { +test('all 16 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -165,18 +113,127 @@ test('raw HTTP transports cannot be added without an explicit graph review', asy discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } - assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort()) + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') + .map(client => client.path).sort() + assert.equal(reviewed.length, 16) + assert.deepEqual(discovered.sort(), reviewed) }) -test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => { - const feignEdges = (await discoverFeignEdges()).keys() - const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges]) - assert.deepEqual(components, [[ +test('every reviewed synchronous client has a complete and exclusive executable classification', () => { + assert.equal(new Set(SYNCHRONOUS_CLIENTS.map(client => client.id)).size, SYNCHRONOUS_CLIENTS.length) + for (const client of SYNCHRONOUS_CLIENTS) { + assert.ok(client.path && client.source && client.target && client.note, `${client.id} metadata is incomplete`) + assert.ok(client.contexts.length > 0, `${client.id} has no execution context`) + assert.equal(new Set(client.contexts).size, client.contexts.length, `${client.id} repeats a context`) + for (const context of client.contexts) assert.ok(CONTEXTS.includes(context), `${client.id} has invalid context ${context}`) + assert.ok(ACCESS_MODES.includes(client.access), `${client.id} has invalid access ${client.access}`) + assert.ok(IDEMPOTENCY.includes(client.idempotency), `${client.id} has invalid idempotency ${client.idempotency}`) + } +}) + +test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { + const clients = new Map([ + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] + ]) + for (const [file, propertyAccessor] of clients) { + const source = await readFile(path.join(root, file), 'utf8') + assert.match(source, /restClientFactory\.build\(restClientBuilder, baseUrl,/) + assert.match(source, new RegExp(`restClientProperties\\.${propertyAccessor}\\(\\)`)) + assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + } + + const factory = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java'), 'utf8') + assert.match(factory, /connectTimeout\(timeouts\.getConnectTimeout\(\)\)/) + assert.match(factory, /setReadTimeout\(timeouts\.getReadTimeout\(\)\)/) + assert.doesNotMatch(factory, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + + for (const configFile of [ + 'lastleg-service/src/main/resources/application.yml', + 'config/nacos/overseas/lastleg-service.yml' + ]) { + const source = await readFile(path.join(root, configFile), 'utf8') + for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + assert.match(source, new RegExp( + `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`)) + } + } +}) + +test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', () => { + assert.deepEqual(graphEdges('REQUEST'), expectedOnlineEdges) + assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) + assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ + 'common-audit->audit-service', + 'gateway-overseas->auth-service', + 'gateway-overseas->overseas-security-service' + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ + 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) +test('overseas security edges never become online business dependencies', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +}) + +test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { + const summary = Object.fromEntries(USER_ENTRIES.map(entry => { + const paths = longestSimplePaths(entry.service) + return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] + })) + assert.deepEqual(summary, { + 'auth-service': { hops: 5, paths: [ + 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'iam-service': { hops: 4, paths: [ + 'iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'order-service': { hops: 4, paths: [ + 'order-service->file-service->iam-service->firstleg-service->print-service', + 'order-service->file-service->iam-service->firstleg-service->warehouse-service', + 'order-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->file-service->iam-service->warehouse-service', + 'order-service->iam-service->firstleg-service->file-service->integration-service' + ] }, + 'firstleg-service': { hops: 4, paths: [ + 'firstleg-service->order-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'finance-service': { hops: 5, paths: [ + 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', + 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'file-service': { hops: 4, paths: [ + 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'lastleg-service': { hops: 5, paths: [ + 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, + 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } + }) + for (const entry of USER_ENTRIES) { + const result = summary[entry.service] + if (result.hops > 2) assert.ok(result.paths.every(path => path.split('->').length > 3)) + } +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { for (const profile of ['local', 'cn', 'overseas']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') From 39988cbaa51607d7b39c974ffa8f2d58f2cb0088 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:00:03 +0800 Subject: [PATCH 180/231] fix(firstleg): publish only actually loaded items --- .../mapper/LoadingCompletedEventMapper.java | 16 ++++++++++++++++ .../LoadingCompletedOutboxServiceTest.java | 18 ++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 8675643a5..f4e907a2a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -29,6 +29,14 @@ public interface LoadingCompletedEventMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED' @@ -46,6 +54,14 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.package_count, i.product_quantity, label.box_code FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick + ON actual_pick.loading_plan_item_id = i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count = i.package_count + JOIN fl_loading_pick_task actual_task + ON actual_task.id = actual_pick.pick_task_id + AND actual_task.loading_plan_id = #{planId} JOIN fl_inbound_pallet_allocation a ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 461aca213..785252006 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -138,11 +138,25 @@ class LoadingCompletedOutboxServiceTest { } @Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception { - String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); - assertThat(sql).contains("p.plan_status = 'COMPLETED'") + assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'"); + for (String sql : List.of(rowsSql, palletsSql)) { + assertThat(sql) + .contains("actual_pick.loading_plan_item_id = i.id") + .contains("actual_pick.item_status IN ('PICKED','PARTIAL')") + .contains("actual_pick.picked_package_count > 0") + .contains("actual_pick.picked_package_count = i.package_count") + .contains("actual_task.id = actual_pick.pick_task_id") + .contains("actual_task.loading_plan_id =") + .doesNotContain("'SKIPPED'") + .doesNotContain("'PENDING'") + .doesNotContain("'PICKING'"); + } } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { From e815abcbd7a17d728bede91d01d336aedeb93cf0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:07:06 +0800 Subject: [PATCH 181/231] fix(firstleg): retain dropship carton label pool --- .../baiyue/firstleg/mapper/LoadingCompletedEventMapper.java | 2 +- .../firstleg/service/LoadingCompletedOutboxServiceTest.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index f4e907a2a..84786775d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -67,7 +67,7 @@ public interface LoadingCompletedEventMapper { AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED') AND a.picked_package_count > 0 LEFT JOIN fl_inbound_carton_label label - ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id + ON label.tenant_id = i.account_id AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE' WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 ORDER BY i.account_id, i.inbound_id, a.allocation_no, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index 785252006..50ff2452d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -157,6 +157,11 @@ class LoadingCompletedOutboxServiceTest { .doesNotContain("'PENDING'") .doesNotContain("'PICKING'"); } + assertThat(palletsSql) + .contains("label.tenant_id = i.account_id") + .contains("label.inbound_detail_id = i.inbound_detail_id") + .contains("label.batch_status = 'ACTIVE'") + .doesNotContain("label.inbound_id = i.inbound_id"); } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { From 44198866d9af725d9192b8e82b924454502119f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:08:34 +0800 Subject: [PATCH 182/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 21 +++ AGENTS.md | 10 +- .../auth/client/IamOnboardingClient.java | 3 +- .../auth/dto/MerchantRegisterResponse.java | 1 + .../service/MerchantRegistrationService.java | 3 +- common-openapi/AGENTS.md | 2 + common-security/AGENTS.md | 3 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 32 ++++ docs/同步调用依赖与失败传播治理基线.md | 21 +-- firstleg/AGENTS.md | 4 + ...rstleg-internal-modularization-baseline.md | 52 +++++- .../scripts/analyze-firstleg-dependencies.mjs | 15 +- .../analyze-firstleg-dependencies.test.mjs | 40 ++++ .../firstleg/BaiyueFirstlegApplication.java | 3 +- .../service/OceanBookingService.java | 8 +- .../persistence}/PriceBookQueryMapper.java | 13 +- .../application/PriceBookQueryService.java | 45 +++++ .../model}/PriceBookQueryRows.java | 2 +- .../port/in/PriceBookQueryUseCase.java | 22 +++ .../port/out/PriceBookQueryReader.java | 21 +++ .../pricing/ActivePriceBookEstimator.java | 8 +- .../pricing/FbaWarehouseRateEstimator.java | 2 +- .../pricing/RegionWeightTierEstimator.java | 2 +- .../repository/PriceBookQueryRepository.java | 53 ------ .../firstleg/service/ContainerService.java | 6 +- .../firstleg/service/FbaPricingService.java | 2 +- .../service/MerchantPriceBookService.java | 8 +- ...BaiyueFirstlegApplicationContractTest.java | 1 + .../controller/PriceBookContractTest.java | 2 +- .../service/OceanBookingServiceTest.java | 6 +- .../PriceBookQueryArchitectureTest.java | 67 +++++++ .../PriceBookQueryServiceTest.java | 46 +++++ .../FbaWarehouseRateEstimatorTest.java | 2 +- .../RegionWeightTierEstimatorTest.java | 2 +- .../service/ContainerServiceTest.java | 6 +- .../CustomerDimensionPricingFlowTest.java | 2 +- .../service/FbaPricingServiceTest.java | 2 +- .../service/MerchantPriceBookServiceTest.java | 12 +- .../firstleg-slice-dependencies.json | 53 ++++-- iam/AGENTS.md | 6 +- .../controller/PlatformTenantController.java | 19 ++ .../com/baiyue/iam/dto/OnboardingDtos.java | 3 +- .../java/com/baiyue/iam/dto/TenantDtos.java | 1 + .../java/com/baiyue/iam/entity/IamTenant.java | 1 + ...CustomerDimensionInitializationStatus.java | 13 ++ .../CustomerDimensionInitializationTask.java | 12 ++ ...omerDimensionInitializationRepository.java | 123 ++++++++++++ .../CustomerDimensionInitializationRelay.java | 99 ++++++++++ ...merDimensionRuleInitializationService.java | 109 ++--------- .../service/MerchantOnboardingService.java | 4 +- .../com/baiyue/iam/service/TenantService.java | 28 +++ ...customer-dimension-initialization-task.sql | 33 ++++ iam/src/main/resources/db/iam.sql | 23 +++ iam/src/main/resources/openapi/iam-v1.yaml | 48 ++++- ...tomerDimensionInitializationRelayTest.java | 104 +++++++++++ ...imensionRuleInitializationServiceTest.java | 149 ++++----------- lastleg-service/AGENTS.md | 5 +- scripts/README.md | 3 +- scripts/agents-index-contract.ps1 | 60 +++++- scripts/java-quality-summary.mjs | 175 ++++++++++++++++++ scripts/java-quality-summary.test.mjs | 77 ++++++++ scripts/synchronous-dependency-baseline.mjs | 3 +- .../synchronous-dependency-contract.test.mjs | 37 ++-- warehouse-overseas-service/AGENTS.md | 4 + 64 files changed, 1382 insertions(+), 360 deletions(-) rename firstleg/src/main/java/com/baiyue/firstleg/{mapper => pricebookquery/adapter/out/persistence}/PriceBookQueryMapper.java (93%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java rename firstleg/src/main/java/com/baiyue/firstleg/{repository => pricebookquery/application/model}/PriceBookQueryRows.java (96%) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java delete mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java create mode 100644 iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java create mode 100644 iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java create mode 100644 iam/src/main/resources/db/customer-dimension-initialization-task.sql create mode 100644 iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java create mode 100644 scripts/java-quality-summary.mjs create mode 100644 scripts/java-quality-summary.test.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 865a9e4b2..5d86bd1dc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,27 @@ OpenAPI有效性: - "**/target/spotbugs*.xml" - "**/target/site/spotbugs.html" +质量摘要: + stage: verify + image: node:22-alpine + cache: [] + allow_failure: true + needs: + - job: OpenAPI有效性 + artifacts: false + - job: 校验与测试 + artifacts: true + - job: 静态分析报告 + artifacts: true + script: + - node --test scripts/java-quality-summary.test.mjs + - node scripts/java-quality-summary.mjs --output target/java-quality-summary.json --require-jacoco --require-spotbugs + artifacts: + when: always + expire_in: 7 days + paths: + - target/java-quality-summary.json + 校验与测试: stage: verify script: diff --git a/AGENTS.md b/AGENTS.md index 5a0872a4a..cf6ed7318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,11 @@ |---|---|---| | 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | +| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` | | 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` | +| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 | | 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` | | 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` | @@ -36,14 +38,15 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;内部入口 `/internal/overseas-security/v1/events`、`/validate`,契约 `src/main/resources/openapi/internal-overseas-security-v1.yaml`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | -| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理库位/托盘调用 `warehouse-overseas-service/`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | +| 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | | 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | +| Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | ## 公共模块快速索引 @@ -80,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 @@ -89,6 +92,7 @@ - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 +- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 - HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。 ## 阶段文档入口 diff --git a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java index b78cc2138..c0bb9d5ed 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java @@ -25,5 +25,6 @@ public interface IamOnboardingClient { String legalPerson, String legalPhone, String contactEmail, String countryCode, String address) { } - record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { } + record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } } diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java index 3c357b850..09ece63c9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterResponse.java @@ -5,5 +5,6 @@ public record MerchantRegisterResponse( Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus, String nextStep ) { } diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java index 09dd998cd..d3fa0f65f 100644 --- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java +++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java @@ -103,7 +103,8 @@ public class MerchantRegistrationService { throw exception; } return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(), - onboarding.tenantStatus(), "LOGIN_AND_COMPLETE_QUALIFICATIONS"); + onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(), + "LOGIN_AND_COMPLETE_QUALIFICATIONS"); } private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) { diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index 59eed2647..f91a255d8 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -13,10 +13,12 @@ | `baiyue.openapi` 配置项 | `src/main/java/com/baiyue/common/openapi/OpenApiProperties.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 长整型 OpenAPI 字符串契约 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` | +| PDA OpenAPI 分组 | `src/main/java/com/baiyue/common/openapi/BaiyueOpenApiAutoConfiguration.java` 的 `pda` GroupedOpenApi;固定匹配 `/api/pda/**`,合同测试 `src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java` | ## 修改与验证 - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 +- PDA Controller 必须进入独立 `pda` 分组;新增或改名 `/api/pda/**` 路径时同步检查分组合同和目标服务 OpenAPI,不得混入平台或商户分组。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 - 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 5829c1000..e9e566096 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -12,7 +12,7 @@ | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | | JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | -| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | +| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | | 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | @@ -22,6 +22,7 @@ - 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e71adf7d2..ca30df085 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16563,6 +16563,38 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE 'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)')); PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt; +-- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。 +USE baiyue_iam; +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED', + attempt_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + lease_owner VARCHAR(128) NULL, + lease_until DATETIME(3) NULL, + last_error_code VARCHAR(32) NULL, + last_error_message VARCHAR(1000) NULL, + remote_config_id BIGINT NULL, + task_version INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 0562b97bb..cf7410e43 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -71,9 +71,9 @@ 当前在线强连通分量为: -`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` +`Auth ↔ IAM`;`Firstleg ↔ Order` -合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 +`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 ### 后台 BACKGROUND 图 @@ -81,7 +81,7 @@ `Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 ### 全图 @@ -97,21 +97,16 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outb | 用户入口 | 最长跳数 | 全部长路径 | |---|---:|---| -| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | -| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | -| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 订单 API | 4 | `Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse` | | 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | -| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | -| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | -| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | -| 国内仓储 API | 0 | 无下游同步调用 | -| 海外仓储 API | 0 | 无下游同步调用 | +| 财务 API | 5 | `Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse` | +| 海外尾程 API/PDA | 3 | `Lastleg → File → IAM → Auth`;`Lastleg → File → IAM → Warehouse` | 这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 -### 第一个建议退出的 REQUEST 边 +### 已完成的第一个 REQUEST 边退出 -建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 +`IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 ## 退出顺序 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index a9afeb4a0..041535f39 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -10,6 +10,7 @@ | 目标 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 | @@ -22,6 +23,7 @@ | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | | 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | | 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | | 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | | 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | @@ -44,7 +46,9 @@ ## 修改约束 +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 diff --git a/firstleg/docs/firstleg-internal-modularization-baseline.md b/firstleg/docs/firstleg-internal-modularization-baseline.md index 7dbecb0d3..df1b0fc9e 100644 --- a/firstleg/docs/firstleg-internal-modularization-baseline.md +++ b/firstleg/docs/firstleg-internal-modularization-baseline.md @@ -1,4 +1,4 @@ -# Firstleg 内部模块化基线与首切片方案 +# Firstleg 内部模块化基线与切片实施记录 ## 1. 范围与生成方式 @@ -198,3 +198,53 @@ mvn -pl firstleg -am -Dtest=CustomerTransitStatisticsServiceTest,CustomerTransit - 根 Mapper 扫描只把旧精确包替换为新精确包,没有扩大扫描范围或增加兼容 Bean。 - 验证结果:Node 合同 3/3;`test-compile` 成功;客户统计加根扫描定向测试 7/7;`mvn -pl firstleg -am test` 共 854 个测试,0 失败、0 错误、3 跳过,构建成功。 - 首次并发执行 `test-compile` 时,其他专项同时写入共享 `target/classes` 导致若干无关 class 文件瞬时缺失;源符号核对正常,独立重跑 `test-compile` 与完整测试均成功。 + +## 12. 第二切片:价格簿只读查询 + +### 12.1 选择证据 + +第二切片选择 `pricebook-query`,而没有选择报关、拖车、出仓或船节点: + +- 原实现只有 `PriceBookQueryRows`、`PriceBookQueryRepository`、`PriceBookQueryMapper` 三个核心文件,全部为只读查询,不承载 Controller、权限或状态机。 +- `PriceBookQueryMapper -> repository.PriceBookQueryRows` 是主源码唯一 `mapper -> repository` 反向依赖,也是物理包 SCC `mapper <-> repository` 的唯一成因。 +- 报关和拖车均涉及 MultipartFile、Feign、装柜或出仓门禁;出仓和船节点属于当前业务热点,风险高于只读价格簿查询。 +- 本切片不触碰 `LoadingPickService`、客户尺寸初始化、IAM 解环、POM、CI、AGENTS、SQL 或部署配置。 + +### 12.2 目标结构与依赖方向 + +```text +pricebookquery/ + application/ + PriceBookQueryService.java + model/PriceBookQueryRows.java + port/in/PriceBookQueryUseCase.java + port/out/PriceBookQueryReader.java + adapter/out/persistence/ + PriceBookQueryMapper.java +``` + +依赖由“多个业务 Service -> 全局 Repository -> 全局 Mapper -> Repository Rows”改为: + +```text +业务消费者 -> PriceBookQueryUseCase +PriceBookQueryService -> PriceBookQueryReader +PriceBookQueryMapper -> PriceBookQueryReader + PriceBookQueryRows +``` + +消费者只能访问 In Port 和只读模型;application 不依赖 Spring MVC、MyBatis、MyBatis-Plus 或其他 Firstleg 切片。Mapper 的七段 `@Select` SQL 文本和方法参数保持不变。 + +### 12.3 依赖图变化 + +- 主源码由 430 类/949 条内部依赖/67 包变为 432 类/953 条内部依赖/72 包;新增项为 In Port、Out Port 和 Application Service,原 Repository 被删除。 +- 物理包多节点 SCC 从 1 个降为 0 个。 +- `pricebook-query` 是零出向边的叶子,不进入逻辑 SCC;显式入向仅为订舱、测量计价、分柜装柜三个消费者切片。 +- 原 `split-loading -> measurement-pricing` 两条价格簿引用被替换为显式 `split-loading -> pricebook-query`;Node 合同没有简单放宽旧边。 +- 既有 88 条跨切片 Entity/DTO 泄漏和 31 条 Mapper 泄漏均未增加。 + +### 12.4 棘轮与回滚边界 + +- Node 合同锁定五个目标类位置、application 依赖禁令、零出向/三类入向和消费者只能访问 In Port/模型,并断言物理包 SCC 为空。 +- `PriceBookQueryArchitectureTest` 用 ArchUnit 执行同等边界,持久化 Mapper 必须实现 Out Port。 +- 回滚仅恢复原三文件及消费者类型引用;不涉及 URI、权限、JSON、SQL、表结构、业务状态或环境配置。 +- 只读模型仍保留原 Lombok 可变 Bean 形态以维持 MyBatis 映射行为;本轮不顺带改造成不可变领域对象。 +- 验证结果:七段 `@Select` SQL 文本逐块完全一致;Node 合同 5/5;`test-compile` 成功;价格簿、ArchUnit、根扫描及三个消费者切片定向测试 78/78;Firstleg 聚合全量 878 个测试,0 失败、0 错误、3 跳过,构建成功。 diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 5b252d7a8..302d466c7 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -13,6 +13,7 @@ const internalPrefix = 'com.baiyue.firstleg.'; const sliceRules = [ ['customer-statistics', /(?:^|\.)(?:customerstatistics)(?:\.|$)/], + ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], @@ -273,8 +274,9 @@ function checkContract(report, contractPath) { if (!actualClass) violations.push(`required class missing: ${fqcn}`); else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); } - const applicationRule = contract.applicationDependencyRule; - if (applicationRule) { + const applicationRules = contract.applicationDependencyRules + ?? (contract.applicationDependencyRule ? [contract.applicationDependencyRule] : []); + for (const applicationRule of applicationRules) { const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); for (const entry of applicationClasses) { for (const imported of entry.imports) { @@ -291,6 +293,15 @@ function checkContract(report, contractPath) { } } } + for (const exposureRule of contract.sliceExposureRules ?? []) { + for (const edge of report.classDependencies) { + if (!edge.source.startsWith(exposureRule.slicePrefix) + && edge.target.startsWith(exposureRule.slicePrefix) + && !(exposureRule.allowedTargetPrefixes ?? []).some(prefix => edge.target.startsWith(prefix))) { + violations.push(`forbidden slice implementation dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); diff --git a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs index 9bcb61d64..101c2f807 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.test.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.test.mjs @@ -52,3 +52,43 @@ test('customer statistics application layer only depends inward', () => { && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('price book query is a leaf slice and removes the package cycle', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.slice === 'pricebook-query'); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper', + 'com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService', + 'com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows', + 'com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase', + 'com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'pricebook-query'), []); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'pricebook-query') + .map(edge => edge.source).sort(), ['booking-customs-trucking', 'measurement-pricing', 'split-loading']); + assert.equal(report.sliceSccs.some(component => component.includes('pricebook-query')), false); + assert.deepEqual(report.packageSccs, []); +}); + +test('price book query application and consumers respect ports', () => { + const report = analyze(); + const prefix = 'com.baiyue.firstleg.pricebookquery.application'; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 4); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.firstleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); + const allowedTargets = [ + 'com.baiyue.firstleg.pricebookquery.application.model.', + 'com.baiyue.firstleg.pricebookquery.application.port.in.', + ]; + const implementationLeaks = report.classDependencies.filter(edge => !edge.source.startsWith('com.baiyue.firstleg.pricebookquery.') + && edge.target.startsWith('com.baiyue.firstleg.pricebookquery.') + && !allowedTargets.some(prefixValue => edge.target.startsWith(prefixValue))); + assert.deepEqual(implementationLeaks, []); +}); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java index 8c071e0be..595ec1411 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java @@ -18,7 +18,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.baiyue.firstleg.outbound.dispatch.mapper", "com.baiyue.firstleg.portschedule.mapper", "com.baiyue.firstleg.customs.mapper", - "com.baiyue.firstleg.customerstatistics.adapter.out.persistence" + "com.baiyue.firstleg.customerstatistics.adapter.out.persistence", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence" }) @EnableFeignClients(basePackages = "com.baiyue.firstleg") @EnableScheduling diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 5db6c6f52..7b4ad9e48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -11,9 +11,9 @@ import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.core.JsonProcessingException; @@ -39,7 +39,7 @@ public class OceanBookingService { private static final Set OPERATIONAL_LIST_STATUSES = Set.of( OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); @Resource private OceanBookingRepository repository; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private ObjectMapper objectMapper; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseMasterDataClient warehouses; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java similarity index 93% rename from firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java index f86961189..8b708f74d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/PriceBookQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java @@ -1,9 +1,10 @@ -package com.baiyue.firstleg.mapper; +package com.baiyue.firstleg.pricebookquery.adapter.out.persistence; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -12,7 +13,7 @@ import java.util.List; /** Dedicated read model for merchant channel selection and reference estimates. */ @Mapper -public interface PriceBookQueryMapper { +public interface PriceBookQueryMapper extends PriceBookQueryReader { @Select(""" SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix, overseas_inbound_code_prefix AS overseasInboundCodePrefix diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java new file mode 100644 index 000000000..094003ec2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.pricebookquery.application; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PriceBookQueryService implements PriceBookQueryUseCase { + @Resource private PriceBookQueryReader reader; + + @Override public Long activeVersionId() { return reader.findActiveVersionId(); } + @Override public List channels(long versionId, long originWarehouseId) { + return reader.findChannels(versionId, originWarehouseId); + } + @Override public List channelTaxModes(long versionId, long originWarehouseId) { + return reader.findChannelTaxModes(versionId, originWarehouseId); + } + @Override public List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode) { + return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); + } + @Override public List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode) { + return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, + pricingTaxMode); + } + @Override public List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode) { + return reader.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); + } + @Override public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { + return channels(versionId, originWarehouseId).stream() + .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); + } + @Override public ChannelPrefixRow channelPrefix(String channelCode) { + return reader.findChannelPrefix(channelCode); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java similarity index 96% rename from firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java rename to firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java index dc8395fc6..0ca9c0b8a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java @@ -1,4 +1,4 @@ -package com.baiyue.firstleg.repository; +package com.baiyue.firstleg.pricebookquery.application.model; import lombok.Getter; import lombok.Setter; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java new file mode 100644 index 000000000..980c86ad7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.pricebookquery.application.port.in; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryUseCase { + Long activeVersionId(); + List channels(long versionId, long originWarehouseId); + List channelTaxModes(long versionId, long originWarehouseId); + List fbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List overseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List regionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + boolean channelAvailable(long versionId, long originWarehouseId, String channelCode); + ChannelPrefixRow channelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java new file mode 100644 index 000000000..04d852aab --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.pricebookquery.application.port.out; + +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; + +import java.util.List; + +public interface PriceBookQueryReader { + Long findActiveVersionId(); + List findChannels(long versionId, long originWarehouseId); + List findChannelTaxModes(long versionId, long originWarehouseId); + List findFbaRates(long versionId, long originWarehouseId, String channelCode, + String destinationCode, String pricingTaxMode); + List findOverseasRates(long versionId, long originWarehouseId, String channelCode, + long destinationWarehouseId, String pricingTaxMode); + List findRegionTiers(long versionId, long originWarehouseId, String channelCode, + String regionCode, String pricingTaxMode); + ChannelPrefixRow findChannelPrefix(String channelCode); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java index e1733566e..003b4085e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/ActivePriceBookEstimator.java @@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,7 +21,7 @@ import java.util.Locale; /** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */ @Component public class ActivePriceBookEstimator { - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private FbaWarehouseRateEstimator fbaEstimator; @Resource private RegionWeightTierEstimator regionEstimator; @Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java index 463252d34..897aef553 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java index 426593a10..3b9d25f6e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimator.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.springframework.stereotype.Component; import java.math.BigDecimal; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java deleted file mode 100644 index 1161bf9e3..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/PriceBookQueryRepository.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.baiyue.firstleg.repository; - -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class PriceBookQueryRepository { - @Resource - private PriceBookQueryMapper mapper; - - public Long activeVersionId() { - return mapper.findActiveVersionId(); - } - - public List channels(long versionId, long originWarehouseId) { - return mapper.findChannels(versionId, originWarehouseId); - } - - public List channelTaxModes(long versionId, long originWarehouseId) { - return mapper.findChannelTaxModes(versionId, originWarehouseId); - } - - public List fbaRates(long versionId, long originWarehouseId, - String channelCode, String destinationCode, - String pricingTaxMode) { - return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode); - } - - public List overseasRates(long versionId, long originWarehouseId, - String channelCode, long destinationWarehouseId, - String pricingTaxMode) { - return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId, - pricingTaxMode); - } - - public List regionTiers(long versionId, long originWarehouseId, - String channelCode, String regionCode, - String pricingTaxMode) { - return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode); - } - - public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) { - return channels(versionId, originWarehouseId).stream() - .anyMatch(channel -> channelCode.equals(channel.getChannelCode())); - } - - public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) { - return mapper.findChannelPrefix(channelCode); - } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index cfc7553c4..7b52c4c8e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -22,8 +22,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity; @@ -44,7 +44,7 @@ public class ContainerService { @Resource private SmartLoadingRepository smartLoadingRepository; @Resource private LoadingPickRepository loadingPickRepository; @Resource private LoadingPickService loadingPickService; - @Resource private PriceBookQueryRepository priceBooks; + @Resource private PriceBookQueryUseCase priceBooks; @Resource private OceanBookingRepository oceanBookings; @Resource private WarehouseSupplierSnapshotService suppliers; @Resource private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index a5f92a869..025f1776b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity; import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import com.baiyue.firstleg.repository.FbaPricingRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java index 87d608bca..d913613d4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantPriceBookService.java @@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest; import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse; import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -26,7 +26,7 @@ import java.util.Map; public class MerchantPriceBookService { public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准"; - @Resource private PriceBookQueryRepository repository; + @Resource private PriceBookQueryUseCase repository; @Resource private ActivePriceBookEstimator estimator; public List channels(long merchantTenantId, long originWarehouseId) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java index 2b9409161..c9ca3a8af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/BaiyueFirstlegApplicationContractTest.java @@ -21,6 +21,7 @@ class BaiyueFirstlegApplicationContractTest { .contains("com.baiyue.firstleg.portschedule.mapper") .contains("com.baiyue.firstleg.customs.mapper") .contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence") + .contains("com.baiyue.firstleg.pricebookquery.adapter.out.persistence") .doesNotContain("com.baiyue.firstleg.customerstatistics.mapper"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java index cccb0a153..5a9a269a2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PriceBookContractTest.java @@ -6,7 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel; import com.baiyue.firstleg.domain.PricingTaxMode; import com.baiyue.firstleg.domain.PricingDestinationType; import com.baiyue.firstleg.dto.PriceBookDtos; -import com.baiyue.firstleg.mapper.PriceBookQueryMapper; +import com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 06019bc33..f9b3ce64a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -9,8 +9,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementE import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,7 +33,7 @@ import static org.mockito.Mockito.*; class OceanBookingServiceTest { @Mock private OceanBookingRepository repository; - @Mock private PriceBookQueryRepository priceBooks; + @Mock private PriceBookQueryUseCase priceBooks; @Mock private WarehouseSupplierSnapshotService suppliers; @Mock private WarehouseMasterDataClient warehouses; @Mock private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java new file mode 100644 index 000000000..3f9734d86 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryArchitectureTest.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class PriceBookQueryArchitectureTest { + private static final String FIRSTLEG = "com.baiyue.firstleg.."; + private static final String SLICE = "com.baiyue.firstleg.pricebookquery.."; + private static final String APPLICATION = "com.baiyue.firstleg.pricebookquery.application.."; + private static final String INPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.in.."; + private static final String OUTPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.out.."; + private static final String MODEL = "com.baiyue.firstleg.pricebookquery.application.model.."; + private static final String PERSISTENCE = "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.firstleg"); + + @Test + void applicationStaysFreeOfWebAndPersistenceFrameworks() { + noClasses().that().resideInAPackage(APPLICATION) + .should().dependOnClassesThat().resideInAnyPackage( + "org.springframework.web..", + "org.apache.ibatis..", + "com.baomidou.mybatisplus..") + .check(PRODUCTION_CLASSES); + } + + @Test + void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() { + classes().that().resideInAPackage(APPLICATION) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION))) + .check(PRODUCTION_CLASSES); + } + + @Test + void persistenceAdapterCollaboratesThroughTheOutputPortAndModel() { + classes().that().resideInAPackage(PERSISTENCE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(SLICE) + .or(resideInAPackage(PERSISTENCE)) + .or(resideInAPackage(OUTPUT_PORT)) + .or(resideInAPackage(MODEL))) + .andShould().beAssignableTo(PriceBookQueryReader.class) + .check(PRODUCTION_CLASSES); + } + + @Test + void consumersOnlySeeTheInputPortAndReadModel() { + noClasses().that().resideOutsideOfPackage(SLICE) + .should().dependOnClassesThat( + resideInAPackage(SLICE) + .and(resideOutsideOfPackage(INPUT_PORT)) + .and(resideOutsideOfPackage(MODEL))) + .check(PRODUCTION_CLASSES); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java new file mode 100644 index 000000000..db339e20a --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricebookquery/PriceBookQueryServiceTest.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.pricebookquery; + +import com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceBookQueryServiceTest { + @Mock private PriceBookQueryReader reader; + @InjectMocks private PriceBookQueryService service; + + @Test + void delegatesStableReadOperationsToTheOutputPort() { + ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT"); + when(reader.findActiveVersionId()).thenReturn(9L); + when(reader.findChannelPrefix("SEA")).thenReturn(prefix); + + assertThat(service.activeVersionId()).isEqualTo(9L); + assertThat(service.channelPrefix("SEA")).isSameAs(prefix); + verify(reader).findActiveVersionId(); + verify(reader).findChannelPrefix("SEA"); + } + + @Test + void keepsChannelAvailabilityAsApplicationLogic() { + ChannelRow channel = new ChannelRow(); + channel.setChannelCode("SEA"); + when(reader.findChannels(9L, 3L)).thenReturn(List.of(channel)); + + assertThat(service.channelAvailable(9L, 3L, "SEA")).isTrue(); + assertThat(service.channelAvailable(9L, 3L, "AIR")).isFalse(); + verify(reader, org.mockito.Mockito.times(2)).findChannels(9L, 3L); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java index c1b94e43d..7fdcef6cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/FbaWarehouseRateEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java index 06fe39203..6c9af0572 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/pricing/RegionWeightTierEstimatorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.pricing; import com.baiyue.common.core.exception.BizException; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 9ac608ba6..8c6f0a5b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -17,8 +17,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper; import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.SmartLoadingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; @@ -51,7 +51,7 @@ class ContainerServiceTest { @MockBean private SmartLoadingRepository smartLoadingRepository; @MockBean private LoadingPickRepository loadingPickRepository; @MockBean private LoadingPickService loadingPickService; - @MockBean private PriceBookQueryRepository priceBooks; + @MockBean private PriceBookQueryUseCase priceBooks; @MockBean private OceanBookingRepository oceanBookings; @MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java index 6f2434cf3..33616c9f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionPricingFlowTest.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.domain.CustomerDimensionBoxType; import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity; import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import org.junit.jupiter.api.Test; import java.math.BigDecimal; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index 823e35dd3..0cefc7b02 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -11,7 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity; import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity; import com.baiyue.firstleg.pricing.ActivePriceBookEstimator; import com.baiyue.firstleg.repository.FbaPricingRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; import jakarta.annotation.Resource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 9412f0928..8f6329212 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -13,11 +13,11 @@ import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator; import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver; import com.baiyue.firstleg.pricing.PriceRegionResolver; import com.baiyue.firstleg.pricing.RegionWeightTierEstimator; -import com.baiyue.firstleg.repository.PriceBookQueryRepository; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow; -import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow; +import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow; +import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -38,7 +38,7 @@ import static org.mockito.Mockito.when; PriceBookWarehouseRegionResolver.class}) @TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") class MerchantPriceBookServiceTest { - @MockBean private PriceBookQueryRepository repository; + @MockBean private PriceBookQueryUseCase repository; @MockBean private PriceBookWarehouseClient warehouses; @Resource private MerchantPriceBookService service; diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 308cda8bc..4ffb1a909 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -5,6 +5,7 @@ "booking-customs-trucking->inbound", "booking-customs-trucking->measurement-pricing", "booking-customs-trucking->outbound", + "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", @@ -19,6 +20,7 @@ "measurement-pricing->booking-customs-trucking", "measurement-pricing->finance-integration", "measurement-pricing->inbound", + "measurement-pricing->pricebook-query", "measurement-pricing->shared-technical", "outbound->booking-customs-trucking", "outbound->shared-technical", @@ -35,8 +37,8 @@ "shipping-nodes->split-loading", "split-loading->booking-customs-trucking", "split-loading->inbound", - "split-loading->measurement-pricing", "split-loading->outbound", + "split-loading->pricebook-query", "split-loading->shared-technical" ], "requiredClassLocations": { @@ -44,21 +46,50 @@ "com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java", "com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java", "com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java", - "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java" - }, - "applicationDependencyRule": { - "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", - "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", - "forbiddenImportPrefixes": [ - "org.springframework.web", - "org.apache.ibatis", - "com.baomidou.mybatisplus" - ] + "com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java", + "com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper": "src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java", + "com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService": "src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java", + "com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows": "src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java", + "com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java", + "com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java" }, + "applicationDependencyRules": [ + { + "packagePrefix": "com.baiyue.firstleg.customerstatistics.application", + "allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + }, + { + "packagePrefix": "com.baiyue.firstleg.pricebookquery.application", + "allowedInternalPrefix": "com.baiyue.firstleg.pricebookquery.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus" + ] + } + ], + "sliceExposureRules": [ + { + "slicePrefix": "com.baiyue.firstleg.pricebookquery.", + "allowedTargetPrefixes": [ + "com.baiyue.firstleg.pricebookquery.application.model.", + "com.baiyue.firstleg.pricebookquery.application.port.in." + ] + } + ], "sliceSpecificRules": { "customer-statistics": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": [] + }, + "pricebook-query": { + "allowedOutgoing": [], + "allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"] } } } diff --git a/iam/AGENTS.md b/iam/AGENTS.md index b085e38c4..ff4a63a71 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -12,10 +12,12 @@ | 目标 | 入口 | |---|---| | 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` | +| 客户默认尺寸初始化、重试与补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults` | | 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` | -| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` | +| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -26,7 +28,9 @@ - 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。 - 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。 +- 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 +- 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 ## 全量验收导航 diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index d51fe0878..9a5e0642c 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse; import com.baiyue.iam.service.TenantGuard; import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.MemberService; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.PlatformCustomerMemberDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -77,6 +78,24 @@ public class PlatformTenantController { return ApiResponse.success(service.merchant(tenantId)); } + @PostMapping("/tenants/{tenantId:\\d+}/dimension-initialization/retry") + @PreAuthorize("hasAuthority('platform:customer:edit')") + @Operation(summary = "重试客户默认尺寸初始化") + public ApiResponse retryDimensionInitialization( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.retryDimensionInitialization(tenantId)); + } + + @GetMapping("/tenants/{tenantId:\\d+}/dimension-initialization") + @PreAuthorize("hasAuthority('platform:customer:list')") + @Operation(summary = "查询客户默认尺寸初始化诊断") + public ApiResponse dimensionInitializationStatus( + @PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.dimensionInitializationStatus(tenantId)); + } + @GetMapping("/tenants/contact-phone-availability") @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") @Operation(summary = "校验客户联系电话唯一性") diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index b00df77b8..266bd6c2d 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -20,7 +20,8 @@ public final class OnboardingDtos { String address ) { } - public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { } + public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus, + String dimensionInitializationStatus) { } public record QualificationSubmissionRequest( @NotNull Long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 40e3422e9..69a08ec1c 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -133,6 +133,7 @@ public final class TenantDtos { @Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要") public record TenantResponse(Long id, String tenantCode, String tenantName, String status, + String dimensionInitializationStatus, String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType, diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 1761b7a81..bc3971d9b 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -22,6 +22,7 @@ public class IamTenant extends BaseEntity { private String tenantName; private String tenantType; private String status; + private String dimensionInitializationStatus; private String legalName; private String legalRepresentative; private String unifiedSocialCreditCode; diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java new file mode 100644 index 000000000..eaf32fa8b --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationStatus.java @@ -0,0 +1,13 @@ +package com.baiyue.iam.model; + +import java.time.LocalDateTime; + +public record CustomerDimensionInitializationStatus( + long tenantId, + String status, + int attemptCount, + LocalDateTime nextRetryAt, + String lastErrorCode, + String lastErrorMessage, + LocalDateTime updatedAt +) { } diff --git a/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java new file mode 100644 index 000000000..51b3aea9a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/model/CustomerDimensionInitializationTask.java @@ -0,0 +1,12 @@ +package com.baiyue.iam.model; + +public record CustomerDimensionInitializationTask( + long tenantId, + long operatorId, + String initializationKey, + String action, + String status, + int attemptCount, + int taskVersion) { +} + diff --git a/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java new file mode 100644 index 000000000..cc9d35ba8 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java @@ -0,0 +1,123 @@ +package com.baiyue.iam.repository; + +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; +import jakarta.annotation.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class CustomerDimensionInitializationRepository { + @Resource private JdbcTemplate jdbc; + + public void enqueue(long tenantId, long operatorId, String initializationKey) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'INITIALIZE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + + public void requestCompensation(long tenantId, long operatorId, String initializationKey) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET operator_id=?," + + "initialization_key=?,action='COMPENSATE',status='PENDING',attempt_count=0," + + "next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=?", + operatorId, initializationKey, tenantId); + if (updated == 0) { + jdbc.update("INSERT INTO iam_customer_dimension_initialization_task(tenant_id,operator_id," + + "initialization_key,action,status,attempt_count,next_retry_at,task_version) " + + "VALUES(?,?,?,'COMPENSATE','PENDING',0,NOW(3),0)", + tenantId, operatorId, initializationKey); + } + } + + public boolean manualRetry(long tenantId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET action='INITIALIZE'," + + "status='PENDING',attempt_count=0,next_retry_at=NOW(3),lease_owner=NULL,lease_until=NULL," + + "last_error_code=NULL,last_error_message=NULL,task_version=task_version+1,updated_at=NOW(3) " + + "WHERE tenant_id=? AND status='FAILED'", tenantId) == 1; + } + + public Optional findStatus(long tenantId) { + List rows = jdbc.query( + "SELECT tenant_id,status,attempt_count,next_retry_at,last_error_code,last_error_message,updated_at " + + "FROM iam_customer_dimension_initialization_task WHERE tenant_id=?", + (rs, row) -> new CustomerDimensionInitializationStatus( + rs.getLong("tenant_id"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getTimestamp("next_retry_at").toLocalDateTime(), rs.getString("last_error_code"), + rs.getString("last_error_message"), rs.getTimestamp("updated_at").toLocalDateTime()), tenantId); + return rows.stream().findFirst(); + } + + public void recoverExpiredLeases() { + jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='PENDING',lease_owner=NULL," + + "lease_until=NULL,next_retry_at=NOW(3),task_version=task_version+1,updated_at=NOW(3) " + + "WHERE status='SENDING' AND lease_until candidates(int limit) { + return jdbc.queryForList("SELECT tenant_id FROM iam_customer_dimension_initialization_task " + + "WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY next_retry_at,tenant_id LIMIT " + + Math.max(1, Math.min(limit, 200)), Long.class); + } + + @Transactional + public CustomerDimensionInitializationTask claim(long tenantId, String workerId, LocalDateTime now) { + int claimed = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='SENDING'," + + "lease_owner=?,lease_until=?,task_version=task_version+1,updated_at=? " + + "WHERE tenant_id=? AND status='PENDING' AND next_retry_at<=?", + workerId, now.plusMinutes(2), now, tenantId, now); + if (claimed != 1) return null; + return jdbc.queryForObject("SELECT tenant_id,operator_id,initialization_key,action,status," + + "attempt_count,task_version FROM iam_customer_dimension_initialization_task " + + "WHERE tenant_id=? AND status='SENDING' AND lease_owner=?", + (rs, row) -> new CustomerDimensionInitializationTask( + rs.getLong("tenant_id"), rs.getLong("operator_id"), rs.getString("initialization_key"), + rs.getString("action"), rs.getString("status"), rs.getInt("attempt_count"), + rs.getInt("task_version")), tenantId, workerId); + } + + @Transactional + public boolean markReady(CustomerDimensionInitializationTask task, String workerId, Long configId) { + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='READY'," + + "remote_config_id=?,lease_owner=NULL,lease_until=NULL,last_error_code=NULL," + + "last_error_message=NULL,completed_at=NOW(3),updated_at=NOW(3) " + + "WHERE tenant_id=? AND action='INITIALIZE' AND status='SENDING' AND lease_owner=? AND task_version=?", + configId, task.tenantId(), workerId, task.taskVersion()); + if (updated == 1) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='READY',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } + + public boolean markCancelled(CustomerDimensionInitializationTask task, String workerId) { + return jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status='CANCELLED'," + + "lease_owner=NULL,lease_until=NULL,last_error_code=NULL,last_error_message=NULL," + + "completed_at=NOW(3),updated_at=NOW(3) WHERE tenant_id=? AND action='COMPENSATE' " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + task.tenantId(), workerId, task.taskVersion()) == 1; + } + + @Transactional + public boolean recordFailure(CustomerDimensionInitializationTask task, String workerId, int nextAttempt, + LocalDateTime nextRetryAt, String code, String message, boolean exhausted) { + String status = exhausted ? "FAILED" : "PENDING"; + int updated = jdbc.update("UPDATE iam_customer_dimension_initialization_task SET status=?,attempt_count=?," + + "next_retry_at=?,lease_owner=NULL,lease_until=NULL,last_error_code=?,last_error_message=?," + + "task_version=task_version+1,updated_at=NOW(3) WHERE tenant_id=? AND action=? " + + "AND status='SENDING' AND lease_owner=? AND task_version=?", + status, nextAttempt, nextRetryAt, code, message, task.tenantId(), task.action(), workerId, + task.taskVersion()); + if (updated == 1 && exhausted && "INITIALIZE".equals(task.action())) { + jdbc.update("UPDATE iam_tenant SET dimension_initialization_status='FAILED',updated_at=NOW(3) " + + "WHERE id=? AND deleted=0", task.tenantId()); + } + return updated == 1; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java new file mode 100644 index 000000000..730b8908a --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -0,0 +1,99 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; + +@Component +@Slf4j +public class CustomerDimensionInitializationRelay { + private static final Set PERMANENT_CODES = Set.of("A0400", "A0403", "A0404", "A0409", "A0422"); + @Resource private CustomerDimensionInitializationRepository repository; + @Resource private CustomerDimensionRuleClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Value("${baiyue.customer-dimension-initialization.max-attempts:12}") private int maxAttempts; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.customer-dimension-initialization.fixed-delay-ms:5000}") + public void relay() { + repository.recoverExpiredLeases(); + for (Long tenantId : repository.candidates(50)) deliver(tenantId); + } + + void deliver(long tenantId) { + CustomerDimensionInitializationTask task = repository.claim(tenantId, workerId, LocalDateTime.now()); + if (task == null) return; + try { + if ("COMPENSATE".equals(task.action())) { + ApiResponse response = client.compensate( + internalToken, task.tenantId(), task.initializationKey()); + requireSuccess(response, false); + repository.markCancelled(task, workerId); + return; + } + ApiResponse response = client.initialize( + internalToken, new CustomerDimensionRuleClient.InitializationRequest( + task.tenantId(), task.operatorId(), task.initializationKey())); + requireSuccess(response, true); + if (response.data() == null || response.data().configId() == null) { + throw new DeliveryFailure("S0503", "Firstleg 未返回默认尺寸配置ID", false); + } + repository.markReady(task, workerId, response.data().configId()); + } catch (Exception failure) { + int nextAttempt = task.attemptCount() + 1; + boolean permanent = failure instanceof DeliveryFailure delivery && delivery.permanent; + boolean exhausted = permanent || nextAttempt >= maxAttempts; + String code = failure instanceof DeliveryFailure delivery ? delivery.code : "S0503"; + String message = safeMessage(failure); + repository.recordFailure(task, workerId, nextAttempt, + LocalDateTime.now().plusSeconds(backoffSeconds(nextAttempt)), code, message, exhausted); + log.warn("客户默认尺寸后台初始化失败 tenantId={}, action={}, attempt={}, exhausted={}, code={}, reason={}", + tenantId, task.action(), nextAttempt, exhausted, code, message); + } + } + + private static void requireSuccess(ApiResponse response, boolean requireData) { + if (response != null && response.successful() && (!requireData || response.data() != null)) return; + String code = response == null || response.code() == null ? "S0503" : response.code(); + String message = response == null || response.message() == null || response.message().isBlank() + ? "Firstleg 服务未确认客户默认尺寸初始化" : response.message(); + throw new DeliveryFailure(code, message, PERMANENT_CODES.contains(code)); + } + + private static long backoffSeconds(int attempt) { + return switch (Math.min(Math.max(attempt, 1), 5)) { + case 1 -> 10; + case 2 -> 30; + case 3 -> 120; + case 4 -> 600; + default -> 1800; + }; + } + + private static String safeMessage(Exception failure) { + String value = failure.getMessage(); + if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); + value = value.replaceAll("[\\r\\n\\t]+", " "); + return value.length() > 1000 ? value.substring(0, 1000) : value; + } + + private static final class DeliveryFailure extends RuntimeException { + private final String code; + private final boolean permanent; + private DeliveryFailure(String code, String message, boolean permanent) { + super(message); + this.code = code; + this.permanent = permanent; + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java index 32bd7217c..c5aaca617 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -1,119 +1,50 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import jakarta.annotation.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */ +/** Owns IAM's durable customer-dimension initialization intent; remote delivery is always background. */ @Service public class CustomerDimensionRuleInitializationService { - private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class); - private static final int MAX_ATTEMPTS = 3; private static final String KEY_PREFIX = "iam-customer-create:"; - - @Resource - private CustomerDimensionRuleClient client; - @Value("${baiyue.security.internal-service-token}") - private String internalToken; + @Resource private CustomerDimensionInitializationRepository repository; public InitializationResult initialize(long tenantId, long operatorId) { String initializationKey = initializationKey(tenantId); - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.initialize( - internalToken, - new CustomerDimensionRuleClient.InitializationRequest( - tenantId, operatorId, initializationKey)); - if (response != null && response.successful() && response.data() != null) { - InitializationResult result = new InitializationResult( - response.data().createdByThisInitialization(), initializationKey); - registerRollbackCompensation(tenantId, result); - return result; - } - lastFailure = unavailable(response); - log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}", - tenantId, attempt, response == null ? null : response.code(), - response == null ? null : response.requestId(), - response == null ? null : response.traceId()); - } catch (RuntimeException failure) { - lastFailure = failure; - log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure); - } - } - compensateAfterUncertainInitialization(tenantId, initializationKey); - throw unavailable(lastFailure); + repository.enqueue(tenantId, operatorId, initializationKey); + return new InitializationResult(false, initializationKey, "PENDING"); } /** Used when Auth compensates a committed client registration. */ public void compensateRegistration(long tenantId) { - compensateRequired(tenantId, initializationKey(tenantId)); + repository.requestCompensation(tenantId, 0L, initializationKey(tenantId)); } - private void registerRollbackCompensation(long tenantId, InitializationResult result) { - if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return; - TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { - @Override - public void afterCompletion(int status) { - if (status != STATUS_ROLLED_BACK) return; - try { - compensateRequired(tenantId, result.initializationKey()); - } catch (RuntimeException compensationFailure) { - log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}", - tenantId, result.initializationKey(), compensationFailure); - } - } - }); - } - - private void compensateAfterUncertainInitialization(long tenantId, String initializationKey) { - try { - compensateRequired(tenantId, initializationKey); - } catch (RuntimeException compensationFailure) { - log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", - tenantId, initializationKey, compensationFailure); + public void retry(long tenantId) { + if (!repository.manualRetry(tenantId)) { + throw new BizException(ResultCode.CONFLICT, "默认尺寸初始化当前不可人工重试"); } } - private void compensateRequired(long tenantId, String initializationKey) { - RuntimeException lastFailure = null; - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - ApiResponse response = client.compensate(internalToken, tenantId, initializationKey); - if (response != null && response.successful()) return; - lastFailure = unavailable(response); - } catch (RuntimeException failure) { - lastFailure = failure; - } + public CustomerDimensionInitializationStatus status(long tenantId) { + return repository.findStatus(tenantId).orElseThrow( + () -> new BizException(ResultCode.NOT_FOUND, "默认尺寸初始化任务不存在")); + } + + public void requireReady(String status) { + if (!"READY".equals(status)) { + throw new BizException(ResultCode.CONFLICT, + "客户默认尺寸尚未就绪,当前状态: " + (status == null ? "PENDING" : status)); } - throw new BizException(ResultCode.SERVICE_UNAVAILABLE, - "客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId); - } - - private static BizException unavailable(ApiResponse response) { - String detail = response == null || response.message() == null || response.message().isBlank() - ? "Firstleg 服务未返回有效响应" : response.message(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail); - } - - private static BizException unavailable(RuntimeException cause) { - String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank() - ? "Firstleg 服务暂时不可用" : cause.getMessage(); - return new BizException(ResultCode.SERVICE_UNAVAILABLE, - "4条默认箱规尺寸初始化失败,已重试 3 次: " + detail); } private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; } - public record InitializationResult(boolean created, String initializationKey) { } + public record InitializationResult(boolean created, String initializationKey, String status) { } } diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index db8c43964..9e71f4848 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -69,6 +69,7 @@ public class MerchantOnboardingService { tenant.setTenantName(request.companyName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.companyName()); tenant.setLegalRepresentative(request.legalPerson()); tenant.setContactName(request.displayName()); @@ -122,7 +123,8 @@ public class MerchantOnboardingService { } dimensionRuleInitialization.initialize(tenantId, request.authUserId()); overseasStorageFeeInitialization.initialize(tenantId, request.authUserId(), tenant.getTenantName()); - return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus()); + return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus(), + tenant.getDimensionInitializationStatus()); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 5d4195185..f29f58f63 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -16,6 +16,7 @@ import com.baiyue.iam.client.AuthAccountClient; import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.entity.IamMembership; import com.baiyue.iam.entity.IamUser; +import com.baiyue.iam.model.CustomerDimensionInitializationStatus; import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; @@ -169,6 +170,7 @@ public class TenantService { tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + tenant.setDimensionInitializationStatus("PENDING"); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setUnifiedSocialCreditCode(null); @@ -271,6 +273,9 @@ public class TenantService { if (tenant == null) { throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在"); } + if (approve) { + dimensionRuleInitialization.requireReady(tenant.getDimensionInitializationStatus()); + } var initializationContext = new TenantApprovalInitializer.ApprovalContext( tenant, reviewId, reviewerId, comment); @@ -321,6 +326,10 @@ public class TenantService { return new OrderEligibilityResponse(false, tenant.getStatus(), "Enterprise information must be approved before ordering"); } + if (!"READY".equals(tenant.getDimensionInitializationStatus())) { + return new OrderEligibilityResponse(false, tenant.getStatus(), + "Customer default dimensions are not ready"); + } if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) { return new OrderEligibilityResponse(false, tenant.getStatus(), "All required enterprise qualifications must be approved"); @@ -338,6 +347,22 @@ public class TenantService { return response(requireMerchant(tenantId)); } + @Transactional + @BusinessAudit(event = "PLATFORM_CUSTOMER_DIMENSION_INITIALIZATION_RETRY", resourceType = "TENANT", + resourceId = "#tenantId", description = "平台重试客户默认尺寸初始化", captureBefore = false) + public TenantResponse retryDimensionInitialization(long tenantId) { + IamTenant tenant = requireMerchantForUpdate(tenantId); + dimensionRuleInitialization.retry(tenantId); + tenant.setDimensionInitializationStatus("PENDING"); + repository.saveTenant(tenant); + return response(tenant); + } + + public CustomerDimensionInitializationStatus dimensionInitializationStatus(long tenantId) { + requireMerchant(tenantId); + return dimensionRuleInitialization.status(tenantId); + } + public boolean contactPhoneAvailable(String contactPhone, Long tenantId) { String normalized = normalizeContactPhone(contactPhone); return tenantId == null @@ -505,6 +530,7 @@ public class TenantService { private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), + tenant.getDimensionInitializationStatus(), tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), @@ -527,6 +553,7 @@ public class TenantService { private TenantResponse summaryResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), @@ -537,6 +564,7 @@ public class TenantService { private TenantResponse merchantVisibleResponse(IamTenant tenant) { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), + response.dimensionInitializationStatus(), response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(), response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), diff --git a/iam/src/main/resources/db/customer-dimension-initialization-task.sql b/iam/src/main/resources/db/customer-dimension-initialization-task.sql new file mode 100644 index 000000000..2f1794d8e --- /dev/null +++ b/iam/src/main/resources/db/customer-dimension-initialization-task.sql @@ -0,0 +1,33 @@ +SET NAMES utf8mb4; +USE baiyue_iam; + +SET @customer_dimension_status_ddl = (SELECT IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='iam_tenant' AND column_name='dimension_initialization_status'), + 'SELECT 1', + 'ALTER TABLE iam_tenant ADD COLUMN dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT ''READY'' COMMENT ''默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败'' AFTER status')); +PREPARE customer_dimension_status_stmt FROM @customer_dimension_status_ddl; +EXECUTE customer_dimension_status_stmt; +DEALLOCATE PREPARE customer_dimension_status_stmt; + +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index c2dfaffb3..bc250abe7 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称', tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户', status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档', + dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败', legal_name VARCHAR(128) NULL COMMENT '企业法定名称', legal_representative VARCHAR(64) NULL COMMENT '法定代表人', unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码', @@ -42,6 +43,28 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( KEY idx_iam_tenant_status (status, created_at) ) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键'; +CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( + tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID', + operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0', + initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', + action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待投递、SENDING投递中、READY已就绪、FAILED失败、CANCELLED已补偿', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', + PRIMARY KEY (tenant_id), + KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), + KEY idx_customer_dimension_task_diagnosis (status,updated_at) +) ENGINE=InnoDB COMMENT='IAM客户默认尺寸可靠后台初始化任务及诊断状态'; + CREATE TABLE IF NOT EXISTS iam_tenant_review ( id BIGINT NOT NULL COMMENT '审核记录ID', tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 4fa22a7e6..a3d146541 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -59,7 +59,7 @@ paths: post: tags: [Platform Tenant / 平台租户] summary: Register enterprise tenant / 创建企业客户草稿 - description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。 + description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Customer creation succeeds after IAM commits; default dimensions initialize reliably in background and are exposed as `PENDING/READY/FAILED`. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据;IAM 提交即代表客户创建成功,默认尺寸在后台可靠初始化并显式返回 PENDING/READY/FAILED。 requestBody: required: true content: @@ -123,6 +123,38 @@ paths: $ref: '#/components/schemas/PlatformCustomerMembersApiResponse' '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization: + parameters: + - {$ref: '#/components/parameters/TenantId'} + get: + tags: [Platform Tenant / 平台租户] + summary: 查询客户默认尺寸初始化诊断 + description: 返回任务状态、尝试次数、下次重试时间和最后失败原因;不会把远端失败转换为空数据或成功。 + responses: + '200': + description: 初始化诊断查询成功 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/CustomerDimensionInitializationStatus'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/dimension-initialization/retry: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 人工重试失败的客户默认尺寸初始化 + description: 只允许 FAILED 任务重新进入 PENDING;稳定 initializationKey 不变。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/tenants/{tenantId}/submit-review: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -1085,12 +1117,13 @@ components: pages: {type: integer, minimum: 0} TenantResponse: type: object - required: [id, tenantCode, tenantName, status, servicePackage] + required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} tenantName: {type: string} status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]} + dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭} legalName: {type: string} legalRepresentative: {type: string, maxLength: 64} unifiedSocialCreditCode: {type: string} @@ -1109,6 +1142,17 @@ components: internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见} createdAt: {type: string, format: date-time, description: 入驻时间} approvedAt: {type: string, format: date-time, description: 审批通过时间} + CustomerDimensionInitializationStatus: + type: object + required: [tenantId, status, attemptCount, nextRetryAt, updatedAt] + properties: + tenantId: {type: string, pattern: '^\d+$'} + status: {type: string, enum: [PENDING, SENDING, READY, FAILED, CANCELLED]} + attemptCount: {type: integer, minimum: 0} + nextRetryAt: {type: string, format: date-time} + lastErrorCode: {type: string, nullable: true} + lastErrorMessage: {type: string, nullable: true, description: 已清洗且截断的最后失败诊断} + updatedAt: {type: string, format: date-time} FinanceCustomerSummary: type: object required: [id, tenantCode, tenantName, status] diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java new file mode 100644 index 000000000..899ead0e7 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -0,0 +1,104 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.iam.client.CustomerDimensionRuleClient; +import com.baiyue.iam.model.CustomerDimensionInitializationTask; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CustomerDimensionInitializationRelayTest { + @Mock private CustomerDimensionInitializationRepository repository; + @Mock private CustomerDimensionRuleClient client; + @InjectMocks private CustomerDimensionInitializationRelay relay; + + @BeforeEach + void configure() { + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void successfulBackgroundInitializationMarksTenantReady() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.success( + new CustomerDimensionRuleClient.InitializationResponse(91L, true))); + + relay.deliver(10001L); + + verify(repository).markReady(task, workerId(), 91L); + verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), any(Boolean.class)); + } + + @Test + void transientFailureStaysPendingWithDiagnosticAndBackoff() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("read timeout")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), exhausted.capture()); + assertFalse(exhausted.getValue()); + } + + @Test + void permanentRemoteRejectionBecomesFailedWithoutDefaultSuccess() { + CustomerDimensionInitializationTask task = task("INITIALIZE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.initialize(anyString(), any())).thenReturn(ApiResponse.failure( + ResultCode.CONFLICT, "invalid customer", "request-1", "trace-1")); + + relay.deliver(10001L); + + ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); + verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + anyString(), exhausted.capture()); + assertTrue(exhausted.getValue()); + verify(repository, never()).markReady(any(), anyString(), any()); + } + + @Test + void compensationIsAlsoDurableAndUsesTheStableKey() { + CustomerDimensionInitializationTask task = task("COMPENSATE", 0); + when(repository.claim(anyLong(), anyString(), any(LocalDateTime.class))).thenReturn(task); + when(client.compensate("internal-token", 10001L, "iam-customer-create:10001")) + .thenReturn(ApiResponse.success(null)); + + relay.deliver(10001L); + + verify(repository).markCancelled(task, workerId()); + } + + private CustomerDimensionInitializationTask task(String action, int attempts) { + return new CustomerDimensionInitializationTask(10001L, 9001L, "iam-customer-create:10001", + action, "SENDING", attempts, 3); + } + + private String workerId() { + return (String) ReflectionTestUtils.getField(relay, "workerId"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java index 459a72fab..b56e9c63d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -1,138 +1,61 @@ package com.baiyue.iam.service; -import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import com.baiyue.iam.client.CustomerDimensionRuleClient; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.AfterEach; +import com.baiyue.iam.repository.CustomerDimensionInitializationRepository; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; - -import java.util.List; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(CustomerDimensionRuleInitializationService.class) -@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +@ExtendWith(MockitoExtension.class) class CustomerDimensionRuleInitializationServiceTest { - @MockBean private CustomerDimensionRuleClient client; - @Resource private CustomerDimensionRuleInitializationService service; - - @AfterEach - void clearSynchronization() { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.clearSynchronization(); - } - } + @Mock private CustomerDimensionInitializationRepository repository; + @InjectMocks private CustomerDimensionRuleInitializationService service; @Test - void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("timeout")) - .thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, - "temporarily unavailable", "request-1", "trace-1")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - + void customerCreationOnlyPersistsBackgroundIntentWithStableKey() { var result = service.initialize(10001L, 9001L); assertFalse(result.created()); - ArgumentCaptor requests = - ArgumentCaptor.forClass(CustomerDimensionRuleClient.InitializationRequest.class); - verify(client, times(3)).initialize(anyString(), requests.capture()); - assertEquals(List.of("iam-customer-create:10001", "iam-customer-create:10001", - "iam-customer-create:10001"), - requests.getAllValues().stream().map( - CustomerDimensionRuleClient.InitializationRequest::initializationKey).toList()); - assertEquals(List.of(9001L, 9001L, 9001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::operatorId).toList()); - assertEquals(List.of(10001L, 10001L, 10001L), requests.getAllValues().stream() - .map(CustomerDimensionRuleClient.InitializationRequest::tenantId).toList()); + assertEquals("PENDING", result.status()); + assertEquals("iam-customer-create:10001", result.initializationKey()); + verify(repository).enqueue(10001L, 9001L, "iam-customer-create:10001"); } @Test - void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() { - when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout")); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - - BizException failure = assertThrows(BizException.class, () -> service.initialize(10001L, 9001L)); - - assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); - verify(client, times(3)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - var result = service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - assertTrue(result.created()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() { - when(client.initialize(anyString(), any())) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false))); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, never()).compensate(anyString(), anyLong(), anyString()); - } - - @Test - void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() { - when(client.initialize(anyString(), any())) - .thenThrow(new RuntimeException("response timeout")) - .thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true))); - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - TransactionSynchronizationManager.initSynchronization(); - - service.initialize(10001L, 9001L); - List synchronizations = - TransactionSynchronizationManager.getSynchronizations(); - TransactionSynchronizationManager.clearSynchronization(); - synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK)); - - verify(client, times(2)).initialize(anyString(), any()); - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); - } - - @Test - void committedClientRegistrationCanBeCompensatedWithItsDeterministicKey() { - when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null)); - + void committedRegistrationCompensationUsesTheSameStableKey() { service.compensateRegistration(10001L); + verify(repository).requestCompensation(10001L, 0L, "iam-customer-create:10001"); + } - verify(client).compensate("internal-token", 10001L, "iam-customer-create:10001"); + @Test + void failedTaskCanBeManuallyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(true); + service.retry(10001L); + verify(repository).manualRetry(10001L); + } + + @Test + void nonFailedTaskCannotBeSilentlyRequeued() { + when(repository.manualRetry(10001L)).thenReturn(false); + BizException failure = assertThrows(BizException.class, () -> service.retry(10001L)); + assertEquals(ResultCode.CONFLICT, failure.getResultCode()); + } + + @Test + void dimensionsDependentFlowFailsClosedUntilReady() { + BizException pending = assertThrows(BizException.class, () -> service.requireReady("PENDING")); + BizException failed = assertThrows(BizException.class, () -> service.requireReady("FAILED")); + assertEquals(ResultCode.CONFLICT, pending.getResultCode()); + assertEquals(ResultCode.CONFLICT, failed.getResultCode()); + service.requireReady("READY"); } } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index f70c92a95..cc6a8ccc8 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -10,6 +10,7 @@ | 功能 | 入口 | |---|---| +| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | @@ -36,7 +37,8 @@ | 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | | 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO | | 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` | -| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/`、`masterdata/exchange/`、`masterdata/vehicle/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`;车辆档案不含派送履约历史 | +| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 | +| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/lastleg/` | | 真实事件消费验收 | `src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java`;覆盖真实 V2 建单、独立入仓单快照及下游转运/换标可消费性 | @@ -91,6 +93,7 @@ ## 修改与验证 +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/scripts/README.md b/scripts/README.md index bfb9577f0..4b1eb9629 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,8 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、海外安全服务部署映射、关键入口与排除目录 | +| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 仅用显式 `--require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index ad8e06621..7ebbd6ded 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -156,6 +156,15 @@ foreach ($gatewayPath in @( Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' +# 海外安全投影是独立部署单元,服务映射、海外编排和 Jenkins 构建入口必须同时存在。 +$serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') +$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') +$serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') +Assert-Contains $serviceMap 'overseas-security-service:' '服务部署映射' +Assert-Contains $overseasCompose 'overseas-security-service:' '海外 Compose' +Assert-Contains $overseasCompose '/internal/overseas-security/v1/events' '海外安全事件消费地址' +Assert-Contains $serviceJenkinsfile "'overseas-security-service'" '服务 Jenkins 构建入口' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -174,6 +183,8 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', + 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/client-web/src/api/overseasHttp.ts', 'apps/client-web/src/modules/overseas-warehouse/routes.ts', 'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts', @@ -203,10 +214,35 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java', + 'baiyue-firstleg/firstleg/docs/firstleg-internal-modularization-baseline.md', + 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs', + 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', + 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/repository/CustomerDimensionInitializationRepository.java', + 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', + 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java', + 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java', + 'baiyue-firstleg/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml', + 'baiyue-firstleg/overseas-security-service/src/main/resources/db/overseas-security.sql', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', @@ -220,6 +256,18 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java', 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalPdaPalletTransitionController.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java', + 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletOperationEntity.java', + 'baiyue-firstleg/lastleg-service/docs/lastleg-internal-modularization-baseline.md', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.mjs', + 'baiyue-firstleg/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs', + 'baiyue-firstleg/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java', @@ -240,6 +288,8 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java', 'baiyue-firstleg/deploy/scripts/deploy-built-service.sh', 'baiyue-firstleg/scripts/deploy-built-service-rollback.test.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.mjs', + 'baiyue-firstleg/scripts/java-quality-summary.test.mjs', 'docs/四期/船节点标准化契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } @@ -255,13 +305,19 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'firstleg/AGENTS.md'), (Join-Path $backendRoot 'finance/AGENTS.md'), (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), + (Join-Path $backendRoot 'iam/AGENTS.md'), + (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-openapi/AGENTS.md'), + (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), + (Join-Path $backendRoot 'overseas-security-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') ) -$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules|node_modules\.broken-by-path|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)' +$excludedTargetPattern = '(?i)`(?:[^`]*/)?(node_modules(?:\.[^/`]*)?|dist|target|reports|output|outputs|uni_modules|\.git|\.codex[^/`]*|\.runtime[^/`]*)(?:/|`)' $excludedPatternSamples = @( '| 错误入口 | `node_modules/pkg/` |', + '| 错误入口 | `node_modules.corrupt/.bin/vite` |', '| 错误入口 | `src/target/classes/` |', '| 错误入口 | `.codex-temp/task/` |', '| 错误入口 | `.runtime-tailleg/DbQuery.java` |' @@ -293,5 +349,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、海外安全服务部署映射、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs new file mode 100644 index 000000000..f54740dca --- /dev/null +++ b/scripts/java-quality-summary.mjs @@ -0,0 +1,175 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) + +const normalize = (value) => value.replaceAll('\\', '/') +const attribute = (source, name) => source.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] + +async function exists(file) { + try { + await stat(file) + return true + } catch (error) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +async function javaSourceCount(moduleDirectory) { + const root = path.join(moduleDirectory, 'src', 'main', 'java') + if (!await exists(root)) return 0 + let count = 0 + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && entry.name.endsWith('.java')) count += 1 + } + } + await walk(root) + return count +} + +async function openApiFiles(moduleDirectory, module) { + const root = path.join(moduleDirectory, 'src', 'main', 'resources', 'openapi') + if (!await exists(root)) return [] + const files = [] + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isDirectory()) await walk(target) + else if (entry.isFile() && /\.(?:json|ya?ml)$/i.test(entry.name)) { + files.push(normalize(path.join(module, path.relative(moduleDirectory, target)))) + } + } + } + await walk(root) + return files.sort() +} + +export function parseModules(pom) { + const block = pom.match(/([\s\S]*?)<\/modules>/)?.[1] ?? '' + return [...block.matchAll(/([^<]+)<\/module>/g)].map((match) => match[1].trim()) +} + +export function parseServices(serviceMap) { + return [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map((match) => match[1]).sort() +} + +export function parseJacocoReport(xml) { + if (!//.test(xml)) throw new Error('not a JaCoCo report') + const counters = [...xml.matchAll(/]*)\/?\s*>/g)] + .map((match) => match[1]) + .filter((source) => attribute(source, 'type') === 'LINE') + if (counters.length === 0) throw new Error('JaCoCo LINE counter is missing') + const summary = counters.at(-1) + const missed = Number(attribute(summary, 'missed')) + const covered = Number(attribute(summary, 'covered')) + if (!Number.isInteger(missed) || !Number.isInteger(covered)) throw new Error('invalid JaCoCo LINE counter') + const total = missed + covered + return { missed, covered, percentage: total === 0 ? null : Number((covered * 100 / total).toFixed(2)) } +} + +export function parseSpotbugsReport(xml) { + if (!//.test(xml)) throw new Error('not a SpotBugs report') + const findings = [...xml.matchAll(/]*)>/g)].map((match) => match[1]) + const byPriority = {} + const byCategory = {} + for (const finding of findings) { + const priority = attribute(finding, 'priority') ?? 'unknown' + const category = attribute(finding, 'category') ?? 'unknown' + byPriority[priority] = (byPriority[priority] ?? 0) + 1 + byCategory[category] = (byCategory[category] ?? 0) + 1 + } + return { findings: findings.length, byPriority, byCategory } +} + +async function reportState(file, parser, root) { + const relative = normalize(path.relative(root, file)) + if (!await exists(file)) return { status: 'missing', path: relative } + try { + return { status: 'valid', path: relative, ...parser(await readFile(file, 'utf8')) } + } catch (error) { + return { status: 'invalid', path: relative, error: error instanceof Error ? error.message : String(error) } + } +} + +export async function buildQualitySummary(root = repositoryRoot) { + const modules = parseModules(await readFile(path.join(root, 'pom.xml'), 'utf8')) + const services = parseServices((await readFile(path.join(root, 'deploy', 'service-map.yml'), 'utf8')).replaceAll('\r\n', '\n')) + const moduleRows = [] + const documents = [] + for (const module of modules) { + const moduleDirectory = path.join(root, module) + const sources = await javaSourceCount(moduleDirectory) + documents.push(...await openApiFiles(moduleDirectory, module)) + if (sources === 0) continue + const jacoco = await reportState(path.join(moduleDirectory, 'target', 'site', 'jacoco', 'jacoco.xml'), parseJacocoReport, root) + const spotbugs = await reportState(path.join(moduleDirectory, 'target', 'spotbugsXml.xml'), parseSpotbugsReport, root) + spotbugs.html = await exists(path.join(moduleDirectory, 'target', 'site', 'spotbugs.html')) + moduleRows.push({ module, javaSources: sources, jacoco, spotbugs }) + } + const coveredServices = [...new Set(documents.map((file) => file.split('/')[0]))] + .filter((module) => services.includes(module)).sort() + const codeFirstServices = services.filter((service) => !coveredServices.includes(service)) + const count = (kind, status) => moduleRows.filter((row) => row[kind].status === status).length + return { + topology: { modules: modules.length, codeModules: moduleRows.length, services: services.length }, + openapi: { documents: documents.length, coveredServices, codeFirstServices, files: documents.sort() }, + jacoco: { + present: count('jacoco', 'valid'), invalid: count('jacoco', 'invalid'), missing: count('jacoco', 'missing') + }, + spotbugs: { + present: count('spotbugs', 'valid'), invalid: count('spotbugs', 'invalid'), + missing: count('spotbugs', 'missing'), findings: moduleRows.reduce((sum, row) => sum + (row.spotbugs.findings ?? 0), 0) + }, + modules: moduleRows + } +} + +export function requirementFailures(summary, options = {}) { + const failures = [] + for (const row of summary.modules) { + if (row.jacoco.status === 'invalid') failures.push(`${row.module}: invalid JaCoCo report (${row.jacoco.error})`) + if (row.spotbugs.status === 'invalid') failures.push(`${row.module}: invalid SpotBugs report (${row.spotbugs.error})`) + if (row.spotbugs.status === 'valid' && !row.spotbugs.html) failures.push(`${row.module}: SpotBugs HTML report is missing`) + if (options.requireJacoco && row.jacoco.status === 'missing') failures.push(`${row.module}: JaCoCo report is missing`) + if (options.requireSpotbugs && row.spotbugs.status === 'missing') failures.push(`${row.module}: SpotBugs report is missing`) + } + return failures +} + +function printSummary(summary) { + process.stdout.write(`Java quality summary: ${summary.topology.modules} modules, ${summary.topology.codeModules} code modules, ${summary.topology.services} services.\n`) + process.stdout.write(`OpenAPI: ${summary.openapi.documents} documents, ${summary.openapi.coveredServices.length}/${summary.topology.services} services; gaps: ${summary.openapi.codeFirstServices.join(', ')}.\n`) + process.stdout.write(`JaCoCo: ${summary.jacoco.present} valid, ${summary.jacoco.missing} missing, ${summary.jacoco.invalid} invalid.\n`) + process.stdout.write(`SpotBugs: ${summary.spotbugs.present} valid, ${summary.spotbugs.missing} missing, ${summary.spotbugs.invalid} invalid, ${summary.spotbugs.findings} findings.\n`) +} + +async function main() { + const args = process.argv.slice(2) + const rootIndex = args.indexOf('--root') + const outputIndex = args.indexOf('--output') + const root = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : repositoryRoot + const summary = await buildQualitySummary(root) + printSummary(summary) + if (outputIndex >= 0) { + const output = path.resolve(args[outputIndex + 1]) + await mkdir(path.dirname(output), { recursive: true }) + await writeFile(output, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + } + const failures = requirementFailures(summary, { + requireJacoco: args.includes('--require-jacoco'), + requireSpotbugs: args.includes('--require-spotbugs') + }) + if (failures.length > 0) throw new Error(`Quality report contract failed:\n${failures.map((failure) => `- ${failure}`).join('\n')}`) +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs new file mode 100644 index 000000000..8e3831751 --- /dev/null +++ b/scripts/java-quality-summary.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { + buildQualitySummary, + parseJacocoReport, + parseSpotbugsReport, + requirementFailures, + repositoryRoot +} from './java-quality-summary.mjs' + +test('parses JaCoCo line coverage and SpotBugs findings without quality thresholds', () => { + assert.deepEqual(parseJacocoReport(''), { + missed: 3, covered: 7, percentage: 70 + }) + assert.deepEqual(parseSpotbugsReport(''), { + findings: 2, + byPriority: { 1: 1, 2: 1 }, + byCategory: { CORRECTNESS: 1, STYLE: 1 } + }) + assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) + assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) +}) + +test('aggregates code modules, report presence and the OpenAPI service matrix', async (t) => { + const root = await mkdtemp(path.join(tmpdir(), 'baiyue-quality-summary-')) + t.after(() => rm(root, { recursive: true, force: true })) + await writeFile(path.join(root, 'pom.xml'), 'bomalphabeta', 'utf8') + await mkdir(path.join(root, 'deploy'), { recursive: true }) + await writeFile(path.join(root, 'deploy', 'service-map.yml'), 'services:\n alpha:\n port: 1\n beta:\n port: 2\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'java'), { recursive: true }) + await mkdir(path.join(root, 'beta', 'src', 'main', 'java'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'java', 'Alpha.java'), 'class Alpha {}', 'utf8') + await writeFile(path.join(root, 'beta', 'src', 'main', 'java', 'Beta.java'), 'class Beta {}', 'utf8') + await mkdir(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'src', 'main', 'resources', 'openapi', 'alpha.yaml'), 'openapi: 3.0.3\n', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site', 'jacoco'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'site', 'jacoco', 'jacoco.xml'), '', 'utf8') + await mkdir(path.join(root, 'alpha', 'target', 'site'), { recursive: true }) + await writeFile(path.join(root, 'alpha', 'target', 'spotbugsXml.xml'), '', 'utf8') + await writeFile(path.join(root, 'alpha', 'target', 'site', 'spotbugs.html'), '', 'utf8') + + const summary = await buildQualitySummary(root) + assert.deepEqual(summary.topology, { modules: 3, codeModules: 2, services: 2 }) + assert.equal(summary.openapi.documents, 1) + assert.deepEqual(summary.openapi.coveredServices, ['alpha']) + assert.deepEqual(summary.openapi.codeFirstServices, ['beta']) + assert.deepEqual(summary.jacoco, { present: 1, invalid: 0, missing: 1 }) + assert.deepEqual(summary.spotbugs, { present: 1, invalid: 0, missing: 1, findings: 1 }) + assert.deepEqual(requirementFailures(summary), []) + assert.deepEqual(requirementFailures(summary, { requireJacoco: true, requireSpotbugs: true }), [ + 'beta: JaCoCo report is missing', 'beta: SpotBugs report is missing' + ]) +}) + +test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { + const summary = await buildQualitySummary(repositoryRoot) + assert.deepEqual(summary.topology, { modules: 23, codeModules: 22, services: 16 }) + assert.equal(summary.openapi.documents, 27) + assert.equal(summary.openapi.coveredServices.length, 8) + assert.equal(summary.openapi.codeFirstServices.length, 8) + + const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') + const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + assert.match(job, /^ allow_failure: true$/m) + assert.match(job, /java-quality-summary\.test\.mjs/) + assert.match(job, /java-quality-summary\.mjs --output target\/java-quality-summary\.json --require-jacoco --require-spotbugs/) + assert.match(job, /^ - job: 校验与测试$/m) + assert.match(job, /^ - job: 静态分析报告$/m) + assert.match(job, /^ - target\/java-quality-summary\.json$/m) + assert.doesNotMatch(job, /threshold|ratchet/i) + assert.doesNotMatch(packageJob, /^ - 质量摘要$/m) +}) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 0e829bb6f..8cea72284 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -88,7 +88,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', - 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + 'iam-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'BUSINESS_KEY', + 'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'), feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index a36533f8c..030af7e92 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -32,7 +32,6 @@ const expectedOnlineEdges = [ 'firstleg-service->print-service', 'firstleg-service->warehouse-service', 'iam-service->auth-service', - 'iam-service->firstleg-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', 'lastleg-service->warehouse-overseas-service', @@ -57,6 +56,7 @@ const expectedBackgroundEdges = [ 'firstleg-service->finance-service', 'firstleg-service->integration-service', 'iam-service->event-bridge-service', + 'iam-service->firstleg-service', 'lastleg-service->event-bridge-service', 'order-service->firstleg-service' ] @@ -172,9 +172,10 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', 'gateway-overseas->auth-service', 'gateway-overseas->overseas-security-service' ]) - assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ - 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' - ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ + ['auth-service', 'iam-service'], + ['firstleg-service', 'order-service'] + ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' ]]) @@ -196,34 +197,32 @@ test('known user entries expose every longest simple REQUEST path over the two-h return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] })) assert.deepEqual(summary, { - 'auth-service': { hops: 5, paths: [ - 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + 'auth-service': { hops: 2, paths: [ + 'auth-service->iam-service->warehouse-service' ] }, - 'iam-service': { hops: 4, paths: [ - 'iam-service->firstleg-service->order-service->file-service->integration-service' + 'iam-service': { hops: 1, paths: [ + 'iam-service->auth-service', + 'iam-service->warehouse-service' ] }, 'order-service': { hops: 4, paths: [ - 'order-service->file-service->iam-service->firstleg-service->print-service', - 'order-service->file-service->iam-service->firstleg-service->warehouse-service', 'order-service->firstleg-service->file-service->iam-service->auth-service', - 'order-service->firstleg-service->file-service->iam-service->warehouse-service', - 'order-service->iam-service->firstleg-service->file-service->integration-service' + 'order-service->firstleg-service->file-service->iam-service->warehouse-service' ] }, 'firstleg-service': { hops: 4, paths: [ 'firstleg-service->order-service->file-service->iam-service->auth-service', 'firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, 'finance-service': { hops: 5, paths: [ - 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', - 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', - 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, - 'file-service': { hops: 4, paths: [ - 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + 'file-service': { hops: 2, paths: [ + 'file-service->iam-service->auth-service', + 'file-service->iam-service->warehouse-service' ] }, - 'lastleg-service': { hops: 5, paths: [ - 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + 'lastleg-service': { hops: 3, paths: [ + 'lastleg-service->file-service->iam-service->auth-service', + 'lastleg-service->file-service->iam-service->warehouse-service' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index c0357deda..c161ebf92 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -13,6 +13,8 @@ | 商户仓库查询 | `controller/MerchantWarehouseController.java` | | 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` | | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | +| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | +| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | @@ -36,6 +38,7 @@ | 仓库地址 | `wh_warehouse_address` | 海外仓地址 | | 服务能力 | `wh_service_capability` | 海外仓服务能力 | | 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | +| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 - `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 @@ -43,4 +46,5 @@ ## 修改与验证 - 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。 +- PDA 托盘流转必须校验内部服务令牌、仓库范围、当前物理状态和幂等键;跨服务业务失败只能通过显式补偿接口恢复,不得由 Lastleg 直接修改 `wh_` 资源表。 - 定向验证:`mvn -pl warehouse-overseas-service -am test`。 From fa92936406bac5b1f55442a6f71a415806592f3b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:16:41 +0800 Subject: [PATCH 183/231] fix(deploy): bind development gateways to loopback --- deploy/scripts/deploy-built-service.sh | 10 ++-------- scripts/test-environment-pipeline-contract.test.mjs | 9 ++++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index b8d703d8c..29707e71e 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -37,10 +37,7 @@ case "$DEPLOY_ENV" in esac if [ "$DEPLOY_ENV" = "development" ]; then - case "$SERVICE" in - gateway|gateway-overseas) PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + PUBLISHED_BIND_ADDRESS=127.0.0.1 else PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi @@ -433,10 +430,7 @@ export DEPLOY_ENV="$deploy_env" export IMAGE_REPOSITORY="$image_repository" export IMAGE_TAG="$image_tag" if [ "$deploy_env" = "development" ]; then - case "$service" in - gateway|gateway-overseas) export PUBLISHED_BIND_ADDRESS=0.0.0.0 ;; - *) export PUBLISHED_BIND_ADDRESS=127.0.0.1 ;; - esac + export PUBLISHED_BIND_ADDRESS=127.0.0.1 else export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" fi diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 22eaf133c..4d324af00 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -85,13 +85,12 @@ test('development and test deployment locks are acquired before target executors assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') }) -test('development exposes gateways for remote acceptance and keeps internal services on loopback', () => { +test('development publishes every service on loopback without changing test defaults', () => { assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PORT:-9000\}:9000/) assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PORT:-9300\}:9300/) - assert.match(directDeployScript, /gateway\|gateway-overseas\) PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) - assert.match(directDeployScript, /\*\) PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.match(directDeployScript, /gateway\|gateway-overseas\) export PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) - assert.match(directDeployScript, /\*\) export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) + assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) }) test('parallel image publications isolate registry credentials per Jenkins job', () => { From 1f6f1a0677d6579fb9343db291d8149bccd5f870 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 15:27:26 +0800 Subject: [PATCH 184/231] test: complete phase4 PDA shipping prerequisites --- scripts/admin-test-env-acceptance.mjs | 70 ++++++++++++++++--- ...-phase4-producer-fixture-contract.test.mjs | 34 ++++++++- ...ase4-cross-region-events-contract.test.mjs | 9 ++- .../phase4-overseas-pda-contract.test.mjs | 12 ++++ .../phase4-overseas-pda.mjs | 66 ++++++++++++++--- ...verseas-acceptance-entry-contract.test.mjs | 8 ++- 6 files changed, 177 insertions(+), 22 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 91fa0303d..49f40a477 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -987,11 +987,15 @@ export class AcceptanceRun { } } - async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD') { + async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { const matrixMarker = this.marker const variantMarker = `${matrixMarker}-P4-${label}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] - const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'] + const createdKeys = [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ] const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) @@ -1009,6 +1013,45 @@ export class AcceptanceRun { await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this)) + const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey + const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null) => { + const matrix = this.createdResources.phase4InboundProducerFixtures + const records = [matrix?.containers, matrix?.byDestinationType?.FBA, + matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) + for (const values of records) { + const target = values.find((item) => item?.resourceKey === producerKey) + if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId }) + } + } + if (producerKey) writeShippingEvidence(false) + if (pickupRequired && !producerKey) { + this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false, + 'phase3LoadingCompletedProducer.resourceKey=missing', + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + if (pickupRequired && producerKey) { + const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url)) + await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this)) + const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url)) + await this.executeModule(`service-center-flow-phase4-${label}`, () => serviceCenter.run(this)) + const portSchedules = await import(new URL('./admin-test-env-modules/phase3-port-schedules.mjs', import.meta.url)) + await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this)) + const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url)) + await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this)) + const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url)) + await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this)) + const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url)) + await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`, + () => carrierNodes.runUntilPickupReady(this)) + const pickup = this.createdResources.phase4CarrierPickupReady + const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true + && Boolean(pickup?.queryId) + writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null) + this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, + shippingReady, + `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } } finally { if (restoreFixture) restoreFixture() this.marker = matrixMarker @@ -1026,21 +1069,32 @@ export class AcceptanceRun { async ensurePhase4PdaProducerMatrix() { const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} if (Number(readiness().dropshipContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP') + await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true) } if (Number(readiness().fbaContainerCount || 0) < 2) { - await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA') + await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', false) } if (Number(readiness().overseasContainerCount || 0) < 1) { - await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true) } if (Number(readiness().distinctContainerCount || 0) < 4) { - await this.runPhase4ProducerVariant('SECONDARY', 'FBA') + await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } const matrix = this.createdResources.phase4InboundProducerFixtures + const containers = Array.isArray(matrix?.containers) ? matrix.containers : [] + const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') + const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') + const awaitingVariants = containers.filter((item) => ['FBA-STANDARD', 'SECONDARY'] + .some((label) => String(item?.resourceKey || '').includes(`-P4-${label}:`))) + const shippingMatrixReady = dropshipReady?.shippingReady === true + && Boolean(dropshipReady?.carrierScheduleQueryId) + && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) + && awaitingVariants.length >= 1 + && awaitingVariants.every((item) => item.shippingReady === false + && item.carrierScheduleQueryId == null) this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, - matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, - `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 6574c4e2c..32699b741 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -81,7 +81,7 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) const entry = read('../admin-test-env-acceptance.mjs') - assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP'\)/) + assert.match(entry, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) assert.match(entry, /orderFulfillmentType/) assert.match(entry, /fulfillmentType: fixtureOrder\.fulfillmentType \|\| 'STANDARD'/) assert.match(entry, /skuCodes: Array\.isArray\(fixtureOrder\.skuCodes\) \? fixtureOrder\.skuCodes : \[\]/) @@ -90,3 +90,35 @@ test('measurement and loading pass destination identity into the LOADED producer assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/) assert.match(pda, /skuFixtures\(context, producer\)/) }) + +test('only PDA-operated variants traverse real outbound and carrier nodes before overseas receiving', () => { + const entry = read('../admin-test-env-acceptance.mjs') + assert.match(entry, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(entry, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(entry, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + const start = entry.indexOf('if (pickupRequired && producerKey)') + const end = entry.indexOf('} finally {', start) + const pickupChain = entry.slice(start, end) + const ordered = [ + 'phase3-customs.mjs', 'service-center-flow.mjs', 'phase3-port-schedules.mjs', + 'phase3-outbound-dispatch.mjs', 'phase3-carrier-schedule-queries.mjs', + 'phase4-carrier-nodes.mjs', 'runUntilPickupReady(this)', + ] + let cursor = -1 + for (const token of ordered) { + const next = pickupChain.indexOf(token) + assert.ok(next > cursor, `${token} must follow the real prerequisite chain`) + cursor = next + } + for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', + 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`)) + assert.match(pickupChain, /shippingReady/) + assert.match(entry, /carrierScheduleQueryId/) + assert.match(pickupChain, /pickup\?\.marker === variantMarker/) + assert.doesNotMatch(pickupChain, /internal\/event-bridge|ll_container_shipping_gate|requestOverseas/) + assert.match(entry, /dropshipReady\?\.shippingReady === true/) + assert.match(entry, /overseasReady\?\.shippingReady === true/) + assert.match(entry, /awaitingVariants\.length >= 1/) + assert.doesNotMatch(entry, /awaitingVariants\.length === 2/) + assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/) +}) diff --git a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs index 7405b5832..dc99c6d41 100644 --- a/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-cross-region-events-contract.test.mjs @@ -80,7 +80,14 @@ test('cross-region normalization keeps existing producers ahead of every phase-t test('phase-four producer variants restore the primary orchestration fixture after each matrix branch', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')) - assert.match(source, /const createdKeys = \['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking'\]/) + const start = source.indexOf('const createdKeys = [', source.indexOf('async runPhase4ProducerVariant')) + const end = source.indexOf(']', start) + const createdKeys = source.slice(start, end) + for (const key of [ + 'phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures', 'oceanBooking', + 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', + 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', + ]) assert.match(createdKeys, new RegExp(`'${key}'`)) }) test('contract covers dual audience, canonical 403, both event directions and diagnostics', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 6b0e61040..ee95227c6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -55,6 +55,18 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) assert.match(source, /现场库存查询只返回Token绑定仓且包含库存位置/) assert.match(source, /作业历史包含本轮柜号收货动作且分页稳定/) + assert.match(source, /awaitProducerConsumption\(context, allProducers\)/) + assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) + assert.match(source, /跨区消费生成尾程订单/) + assert.match(source, /awaitPickupReadyCandidates\(context, primary\.token, producers, allProducers\)/) + assert.match(source, /missingPickupReady=/) + assert.match(source, /item\?\.shippingReady === false/) + assert.match(source, /!expectedSet\.has\(id\(item\?\.containerNo\)\)/) + assert.match(source, /unexpectedAwaiting=/) + assert.match(source, /missingPickupReady\.length === 0 && unexpectedAwaiting\.length === 0/) + assert.doesNotMatch(source, /shippingReady == null|shippingReady !== true/) + assert.match(source, /unexpectedHistorical=/) + assert.doesNotMatch(source, /awaitProducerCandidates/) }) test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e7e7040c1..b1f177e91 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -205,22 +205,63 @@ async function uploadPhotos(context, token) { return photos } -async function awaitProducerCandidates(context, token, producers) { +async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + if (!expected.length) return true + const consumed = new Set() + const lastResponses = new Map() + for (let attempt = 1; attempt <= 30; attempt += 1) { + const missing = expected.filter((containerNo) => !consumed.has(containerNo)) + const results = await Promise.all(missing.map(async (containerNo) => { + const response = await context.requestOverseas('GET', + `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + return { containerNo, response } + })) + for (const { containerNo, response } of results) { + lastResponses.set(containerNo, response) + const rows = records(response?.body?.data) + if (success(response) && rows.some((item) => id(item?.containerNo) === containerNo)) { + consumed.add(containerNo) + } + } + if (expected.every((containerNo) => consumed.has(containerNo))) break + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + for (const containerNo of expected) { + const response = lastResponses.get(containerNo) || null + context.addStep(MODULE, `跨区消费生成尾程订单 ${containerNo}`, response, + consumed.has(containerNo), + `containerNo=${containerNo}, consumed=${consumed.has(containerNo)}; ${trace(response)}`, OPTIONS) + } + return expected.every((containerNo) => consumed.has(containerNo)) +} + +async function awaitPickupReadyCandidates(context, token, producers, allProducers) { + const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] + const expectedSet = new Set(expected) + const awaiting = [...new Set(allProducers + .filter((item) => item?.shippingReady === false && !expectedSet.has(id(item?.containerNo))) + .map((item) => id(item?.containerNo)).filter(nonBlank))] + const known = new Set(allProducers.map((item) => id(item?.containerNo)).filter(nonBlank)) if (!expected.length) return false let response = null let seen = new Set() for (let attempt = 1; attempt <= 30; attempt += 1) { response = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) - const records = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] - seen = new Set(records.map((item) => id(item?.containerNo)).filter(nonBlank)) - if (expected.every((containerNo) => seen.has(containerNo))) break + const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] + seen = new Set(candidateRows.map((item) => id(item?.containerNo)).filter(nonBlank)) + if (expected.every((containerNo) => seen.has(containerNo)) + && awaiting.every((containerNo) => !seen.has(containerNo))) break await new Promise((resolve) => setTimeout(resolve, 1000)) } - const ready = expected.every((containerNo) => seen.has(containerNo)) - context.addStep(MODULE, '三期生产矩阵全部进入海外待收候选后再开始PDA', response, ready, - `expected=${expected.join('|')}, seen=${[...seen].join('|')}; ${trace(response)}`, OPTIONS) + const seenExpected = expected.filter((containerNo) => seen.has(containerNo)) + const missingPickupReady = expected.filter((containerNo) => !seen.has(containerNo)) + const unexpectedAwaiting = awaiting.filter((containerNo) => seen.has(containerNo)) + const unexpectedHistorical = [...seen].filter((containerNo) => !known.has(containerNo)) + const ready = missingPickupReady.length === 0 && unexpectedAwaiting.length === 0 + context.addStep(MODULE, '实际处理柜全部达到PICKUP_READY并进入PDA候选', response, ready, + `expected=${expected.join('|')}, awaiting=${awaiting.join('|') || 'none'}, seenExpected=${seenExpected.join('|') || 'none'}, missingPickupReady=${missingPickupReady.join('|') || 'none'}, unexpectedAwaiting=${unexpectedAwaiting.join('|') || 'none'}, unexpectedHistorical=${unexpectedHistorical.join('|') || 'none'}; ${trace(response)}`, OPTIONS) return ready } @@ -501,9 +542,14 @@ export async function run(context) { const producers = [dropship || matrix?.byDestinationType?.FBA?.[0], matrix?.byDestinationType?.OVERSEAS?.[0]] .filter((item) => nonBlank(item?.containerNo)) const allProducers = Array.isArray(matrix?.containers) ? matrix.containers : producers - if (allProducers.length && !(await awaitProducerCandidates(context, primary.token, allProducers))) { - blockerGap(context, '海外待收柜生产矩阵', - '三期已发布的FBA、OVERSEAS和第二柜尚未全部被海外事件消费者提交,禁止与消费事务并发入库') + if (allProducers.length && !(await awaitProducerConsumption(context, allProducers))) { + blockerGap(context, '跨区消费生产矩阵', + '三期已发布的四柜尚未全部由海外消费者生成尾程订单,禁止把PICKUP_READY缺口与事件消费混为一谈') + return + } + if (producers.length && !(await awaitPickupReadyCandidates(context, primary.token, producers, allProducers))) { + blockerGap(context, '实际处理柜PICKUP_READY门禁', + 'Dropship/FBA与OVERSEAS实际处理柜尚未全部达到PICKUP_READY;跨区消费已独立验证,不回退处理历史柜') return } for (const [index, producer] of (producers.length ? producers : [null]).entries()) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 05baae375..6bfe64c05 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -80,8 +80,12 @@ test('PDA entry expands the real phase-three producer chain and builds an FBA/OV assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) } assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) - assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) - assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /runPhase4ProducerVariant\('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS', 'STANDARD', true\)/) + assert.match(source, /runPhase4ProducerVariant\('FBA-STANDARD', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA', 'STANDARD', false\)/) + assert.match(source, /runUntilPickupReady\(this\)/) + assert.match(source, /carrierScheduleQueryId/) assert.match(source, /readyForPhase4Pda === true/) assert.equal(modules.indexOf('phase4-overseas-pda-operations'), modules.indexOf('phase4-overseas-pda') + 1) }) From 87c5e18ca6179791597a910302c852ff3b3c511c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:48 +0800 Subject: [PATCH 185/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 1 - AGENTS.md | 4 +- Jenkinsfile | 4 +- Jenkinsfile.service | 3 +- README.md | 2 +- config/nacos/cn/iam-service.yml | 3 + .../nacos/overseas/event-bridge-service.yml | 2 +- config/nacos/overseas/gateway-overseas.yml | 4 +- .../overseas/overseas-security-service.yml | 7 -- deploy/docker-compose.overseas.yml | 19 +--- deploy/docker-compose.yml | 1 + deploy/scripts/deploy-built-service.sh | 13 +-- deploy/scripts/deploy-services.sh | 15 +-- deploy/service-map.yml | 5 - deploy/sql/README.md | 3 +- deploy/sql/海外区域统一增量-MySQL8.sql | 50 ++++++---- docs/头程项目目录结构与服务说明.md | 8 +- event-bridge-service/AGENTS.md | 2 +- .../metrics/EventBridgeMetrics.java | 2 + .../HttpSecurityProjectionEventClient.java | 12 ++- .../src/main/resources/application.yml | 2 +- .../metrics/EventBridgeMetricsTest.java | 20 ++++ ...HttpSecurityProjectionEventClientTest.java | 44 +++++++++ .../OverseasSecurityProjectionProperties.java | 2 +- .../HttpOverseasSecurityValidationClient.java | 8 +- .../OverseasSecurityProjectionFilter.java | 5 +- .../OverseasSecurityValidationClient.java | 2 +- .../src/main/resources/application.yml | 4 +- ...urityValidationDeploymentContractTest.java | 42 ++++++++ .../OverseasSecurityProjectionFilterTest.java | 8 +- iam/AGENTS.md | 3 +- iam/SECURITY-EVENT-AUDIT.md | 4 +- .../iam/client/SecurityEventBridgeClient.java | 3 +- .../com/baiyue/iam/service/MemberService.java | 2 + .../service/MerchantOnboardingService.java | 4 + .../service/PlatformInternalUserService.java | 8 ++ .../PlatformUserManagementService.java | 2 + .../iam/service/SecurityEventOutboxRelay.java | 4 + .../service/SecurityEventOutboxService.java | 3 +- ...enantAdministratorProvisioningService.java | 11 +++ .../com/baiyue/iam/service/TenantService.java | 2 + iam/src/main/resources/application.yml | 3 + ...SecurityEventBridgeClientContractTest.java | 35 +++++++ .../baiyue/iam/service/MemberServiceTest.java | 22 +++++ .../MerchantOnboardingServiceTest.java | 6 ++ .../PlatformInternalUserServiceTest.java | 33 +++++++ .../PlatformUserManagementServiceTest.java | 26 +++++ .../service/SecurityEventOutboxRelayTest.java | 91 ++++++++++++++++++ .../SecurityEventOutboxServiceTest.java | 28 ++++++ ...tAdministratorProvisioningServiceTest.java | 11 +++ .../baiyue/iam/service/TenantServiceTest.java | 3 + lastleg-service/AGENTS.md | 6 ++ lastleg-service/pom.xml | 1 + .../scripts/analyze-lastleg-dependencies.mjs | 1 + .../analyze-lastleg-dependencies.test.mjs | 22 +++++ .../controller/LastlegSecurityController.java | 27 +++--- .../security/dto/LastlegSecurityDtos.java | 13 ++- .../LastlegSecurityProjectionRedisMirror.java | 8 +- .../LastlegSecurityProjectionService.java | 95 +++++++++++++------ .../LastlegSecurityValidationService.java | 60 ++++++++++++ .../SecurityProjectionMonotonicity.java | 6 +- .../src/main/resources/application.yml | 2 +- .../src/main/resources/db/lastleg.sql | 39 ++++++++ .../InternalServiceTokenGuardTest.java | 6 ++ .../security/LastlegSecurityContractTest.java | 75 +++++++++++++++ .../SecurityProjectionArchitectureTest.java | 49 ++++++++++ ...astlegSecurityProjectionTransportTest.java | 54 +++++++++++ .../LastlegSecurityValidationServiceTest.java | 67 +++++++++++++ .../SecurityProjectionMonotonicityTest.java | 28 ++++++ .../lastleg-slice-dependencies.json | 13 ++- overseas-security-service/AGENTS.md | 14 --- overseas-security-service/pom.xml | 22 ----- .../OverseasSecurityApplication.java | 11 --- .../security/InternalServiceTokenGuard.java | 26 ----- .../OverseasSecurityValidationService.java | 44 --------- .../src/main/resources/application.yml | 28 ------ .../main/resources/db/overseas-security.sql | 31 ------ .../internal-overseas-security-v1.yaml | 26 ----- .../OverseasSecurityContractTest.java | 51 ---------- ...OverseasSecurityValidationServiceTest.java | 36 ------- .../service/ProjectionMonotonicityTest.java | 28 ------ pom.xml | 1 - scripts/admin-test-env-acceptance.mjs | 49 +++++++++- .../customer-system-contract.test.mjs | 10 ++ scripts/agents-index-contract.ps1 | 28 +++--- scripts/java-quality-summary.test.mjs | 6 +- scripts/java-topology-contract.test.mjs | 8 +- scripts/overseas-deployment-contract.test.mjs | 27 ++++-- ...verseas-acceptance-entry-contract.test.mjs | 39 +++++++- .../provision-development-service-jobs.groovy | 2 +- scripts/synchronous-dependency-baseline.mjs | 4 +- .../synchronous-dependency-contract.test.mjs | 15 +-- ...est-environment-pipeline-contract.test.mjs | 2 + 93 files changed, 1154 insertions(+), 527 deletions(-) delete mode 100644 config/nacos/overseas/overseas-security-service.yml create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java create mode 100644 gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java create mode 100644 iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java (60%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java (74%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java (82%) rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java (66%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java rename overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java => lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java (82%) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java delete mode 100644 overseas-security-service/AGENTS.md delete mode 100644 overseas-security-service/pom.xml delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java delete mode 100644 overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java delete mode 100644 overseas-security-service/src/main/resources/application.yml delete mode 100644 overseas-security-service/src/main/resources/db/overseas-security.sql delete mode 100644 overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java delete mode 100644 overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d86bd1dc..3fd5c31fe 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -97,7 +97,6 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar - - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index cf6ed7318..8fd132bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | -| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;内部入口 `/internal/overseas-security/v1/events`、`/validate`,契约 `src/main/resources/openapi/internal-overseas-security-v1.yaml`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验 | +| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、海外安全服务部署映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/Jenkinsfile b/Jenkinsfile index be2593cce..a06ba1017 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,7 +17,6 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -69,7 +68,6 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 88c8bac39..bd81515d2 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,7 +17,6 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index 604e465ae..0f2913388 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 32dcc05c7..48a0d8795 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -5,7 +5,10 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} + diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index bbc198d5c..00a8ac755 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,7 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index 11e2deb34..a9c1eadf2 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -4,6 +4,6 @@ springdoc: baiyue: security: overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml deleted file mode 100644 index 80565c445..000000000 --- a/config/nacos/overseas/overseas-security-service.yml +++ /dev/null @@ -1,7 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index f41f67f15..aae9708e7 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,8 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} - OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} + LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} + LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -94,25 +94,12 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} + LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] - overseas-security-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security - ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] - volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] - environment: - <<: *overseas-environment - SERVER_PORT: 9307 - OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} - OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} - OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} - networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4f6700005..8e61b9ec7 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -79,6 +79,7 @@ services: IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} + SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index 29707e71e..d4ad1ad7d 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -64,7 +64,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -98,15 +98,6 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi -if [ "$SERVICE" = "overseas-security-service" ]; then - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -124,7 +115,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) + gateway-overseas|lastleg-service|warehouse-overseas-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index abdbafef5..7c9983f82 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,17 +80,6 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi -case " $SERVICES " in - *" overseas-security-service "*) - for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e78b8a6e5..e22eec804 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,11 +8,6 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] - overseas-security-service: - artifactId: overseas-security-service - jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar - port: 9307 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dc681dba9..f857cf41f 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,8 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 41f10d449..882d2a042 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -920,33 +920,47 @@ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, - payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, - received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', processed_at DATETIME(3) NULL COMMENT '处理完成时间', PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, - security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, - payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, - changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; -DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +-- 与国内固定 auth_account.id=1000001、tenant_id=0 的 platform_admin 一致;真实事件从版本1覆盖。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); + +DROP PROCEDURE IF EXISTS assert_lastleg_security_projection_ready; DELIMITER $$ -CREATE PROCEDURE assert_overseas_security_projection_ready() +CREATE PROCEDURE assert_lastleg_security_projection_ready() BEGIN - IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' - AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_security_event_inbox','ll_security_projection')) <> 2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; END IF; END$$ DELIMITER ; -CALL assert_overseas_security_projection_ready(); -DROP PROCEDURE assert_overseas_security_projection_ready; +CALL assert_lastleg_security_projection_ready(); +DROP PROCEDURE assert_lastleg_security_projection_ready; SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 2ed49423a..f16f95e37 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,7 +48,6 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 -├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -100,9 +99,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -283,6 +281,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 14ed05d26..4b88bd092 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,7 +12,7 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java index 50a3bbe66..85756ae0e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -6,6 +6,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import java.time.Clock; @@ -41,6 +42,7 @@ public class EventBridgeMetrics { private final MeterRegistry registry; private final Clock clock; + @Autowired public EventBridgeMetrics(MeterRegistry registry) { this(registry, Clock.systemDefaultZone()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index e18cf8714..6c4b7666e 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -47,10 +47,14 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven JsonNode body; try { body = objectMapper.readTree(response.body()); } catch (Exception exception) { body = null; } - if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) - || !row.getEventId().equals(body.path("data").path("eventId").asText())) { - throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); - } + requireAcknowledgement(response.statusCode(), body, row.getEventId()); return String.valueOf(response.statusCode()); } + + static void requireAcknowledgement(int statusCode, JsonNode body, String eventId) { + if (statusCode / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !eventId.equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("Lastleg 安全投影消费失败: HTTP " + statusCode); + } + } } diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index fc5524546..b20743a41 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,7 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} - security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} + security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java index 39a229241..636db964f 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -8,12 +8,32 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Modifier; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.Mockito.mock; class EventBridgeMetricsTest { + @Test + void productionConstructorIsExplicitForSpringWhenTestClockConstructorAlsoExists() throws Exception { + assertThat(EventBridgeMetrics.class.getConstructor(MeterRegistry.class) + .isAnnotationPresent(Autowired.class)).isTrue(); + } + + @Test + void springContextCreatesMetricsBeanWithTheAnnotatedProductionConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.registerBean(MeterRegistry.class, SimpleMeterRegistry::new); + context.register(EventBridgeMetrics.class); + context.refresh(); + + assertThat(context.getBean(EventBridgeMetrics.class)).isNotNull(); + } + } + @Test void collapsesUnboundedRegionAndEventTypeLabelsToOther() { SimpleMeterRegistry registry = new SimpleMeterRegistry(); diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java new file mode 100644 index 000000000..9a31b6db0 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClientTest.java @@ -0,0 +1,44 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class HttpSecurityProjectionEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void onlyMatchingLastlegAcknowledgementCompletesBridgeDelivery() throws Exception { + var acknowledged = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-1","result":"APPLIED"}} + """); + var businessFailure = objectMapper.readTree(""" + {"code":"A0409","message":"payloadHash 冲突","data":{"eventId":"event-1"}} + """); + var wrongEvent = objectMapper.readTree(""" + {"code":"200","data":{"eventId":"event-2","result":"DUPLICATE"}} + """); + + assertThatCode(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, acknowledged, "event-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, businessFailure, "event-1")).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> HttpSecurityProjectionEventClient.requireAcknowledgement( + 200, wrongEvent, "event-1")).isInstanceOf(IllegalStateException.class); + } + + @Test + void defaultConsumerUrlTargetsLastlegSecurityEndpoint() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + + assertThat(application).contains( + "LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events") + .doesNotContain("/internal/overseas-security/v1/events"); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java index 9edcfad81..e488beaa9 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -7,7 +7,7 @@ import java.time.Duration; @ConfigurationProperties(prefix = "baiyue.security.overseas-projection") public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { public OverseasSecurityProjectionProperties { - if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 LASTLEG_SECURITY_SERVICE_URL"); if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java index 6d75282df..17f3af48b 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -9,7 +9,7 @@ import reactor.core.publisher.Mono; @Component public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { - private static final String PATH = "/internal/overseas-security/v1/validate"; + private static final String PATH = "/internal/lastleg/security/v1/validate"; private final WebClient client; private final OverseasSecurityProjectionProperties properties; @@ -19,10 +19,10 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal } @Override - public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + public Mono validate(long userId, long tenantId, long permissionVersion) { return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) .header("X-Internal-Service-Token", properties.internalServiceToken()) - .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .bodyValue(new Request(userId, tenantId, permissionVersion)) .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { JsonNode data = body == null ? null : body.path("data"); if (data == null || data.isMissingNode() || !data.has("valid")) { @@ -33,5 +33,5 @@ public class HttpOverseasSecurityValidationClient implements OverseasSecurityVal }); } - private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } + private record Request(long userId, long tenantId, long permissionVersion) { } } diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java index cdbb4e727..91fc516cb 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -38,12 +38,11 @@ public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { JwtAuthenticationToken authentication) { Long userId = number(authentication.getToken().getSubject()); Long tenantId = number(authentication.getToken().getClaim("tenant_id")); - Long tokenVersion = number(authentication.getToken().getClaim("token_version")); Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); - if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + if (userId == null || tenantId == null || permissionVersion == null) { return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); } - return client.validate(userId, tenantId, tokenVersion, permissionVersion) + return client.validate(userId, tenantId, permissionVersion) .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) .flatMap(result -> { if (result.valid()) return chain.filter(exchange); diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java index 2ce22a244..a267cb233 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -3,6 +3,6 @@ package com.baiyue.gateway.filter; import reactor.core.publisher.Mono; public interface OverseasSecurityValidationClient { - Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + Mono validate(long userId, long tenantId, long permissionVersion); record Result(boolean valid, String failureReason) { } } diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 3e7ce51f2..d61d0ec19 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -47,9 +47,9 @@ baiyue: internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} overseas-projection: - service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} + timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java new file mode 100644 index 000000000..45bd73e1d --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasSecurityValidationDeploymentContractTest.java @@ -0,0 +1,42 @@ +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 OverseasSecurityValidationDeploymentContractTest { + @Test + void gatewayAndEventBridgeUseLastlegSecurityEndpointsWithoutTheRetiredService() throws Exception { + String client = read("gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java"); + String gatewayApplication = read("gateway-overseas/src/main/resources/application.yml"); + String gatewayNacos = read("config/nacos/overseas/gateway-overseas.yml"); + String bridgeApplication = read("event-bridge-service/src/main/resources/application.yml"); + String bridgeNacos = read("config/nacos/overseas/event-bridge-service.yml"); + String compose = read("deploy/docker-compose.overseas.yml"); + + assertThat(client).contains("/internal/lastleg/security/v1/validate") + .contains("new Request(userId, tenantId, permissionVersion)") + .doesNotContain("new Request(userId, tenantId, tokenVersion") + .doesNotContain("/internal/overseas-security/"); + for (String source : new String[]{gatewayApplication, gatewayNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_SERVICE_URL", "http://lastleg-service:9303"); + } + for (String source : new String[]{bridgeApplication, bridgeNacos, compose}) { + assertThat(source).contains("LASTLEG_SECURITY_EVENT_CONSUMER_URL", + "/internal/lastleg/security/v1/events"); + } + assertThat(gatewayApplication + gatewayNacos + bridgeApplication + bridgeNacos + compose) + .doesNotContain("overseas-security-service", "9307", "OVERSEAS_SECURITY_DB_URL", + "OVERSEAS_SECURITY_SERVICE_URL", "OVERSEAS_SECURITY_EVENT_CONSUMER_URL"); + } + + 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/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java index ff9f935f0..1857158e8 100644 --- a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -26,7 +26,7 @@ class OverseasSecurityProjectionFilterTest { private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); @Test void disabledUserFailsWithA0401() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -36,7 +36,7 @@ class OverseasSecurityProjectionFilterTest { @Test void disabledTenantAndStalePermissionFailWithA0403() { for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(false, reason))); var exchange = exchange("/api/merchant/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); @@ -46,14 +46,14 @@ class OverseasSecurityProjectionFilterTest { } @Test void projectionUnavailableFailsClosed() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + when(client.validate(42, 0, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); var exchange = exchange("/api/platform/v1/last-leg/orders"); StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { - when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + when(client.validate(42, 0, 8)).thenReturn(Mono.just( new OverseasSecurityValidationClient.Result(true, null))); AtomicBoolean routed = new AtomicBoolean(); StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { diff --git a/iam/AGENTS.md b/iam/AGENTS.md index ff4a63a71..be2a0b5e5 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -17,7 +17,7 @@ | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | -| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../overseas-security-service/` | +| 海外安全状态事件 Outbox | `service/SecurityEventOutboxService.java`、`SecurityEventOutboxRelay.java`、`client/SecurityEventBridgeClient.java`;账号、授权或客户状态变更经版本化事件投影到 `../lastleg-service/security/` | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | | File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` | | DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` | @@ -31,6 +31,7 @@ - 客户建档只在 IAM 事务内落持久初始化意图,远端投递由带租约、退避和最大次数的 Relay 完成;只有状态为 `READY` 才能审核通过。注册补偿必须投递 `COMPENSATE`,不得在建档事务中同步依赖 Firstleg。 - 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。 - 只有平台 `WAREHOUSE_PICKER` 可保存 `overseasWarehouseId`,且必须为单个有效海外仓;非拣货员必须为空。仓绑定、账号状态、角色权限或客户状态变化要在同一业务事务内推进安全事件 Outbox,不得依赖海外服务回查国内 IAM。 +- `IamTenantStatusChanged` 是安全可用性快照,不暴露客户建档/审核流程状态:仅 IAM 业务状态 `ACTIVE` 发布 `ACTIVE`,`PROFILE_REQUIRED`、`PENDING`、`REJECTED`、`DISABLED`、`ARCHIVED` 及其他不可用状态统一发布 `DISABLED`。 ## 全量验收导航 diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md index 0f0a49f51..856b61edf 100644 --- a/iam/SECURITY-EVENT-AUDIT.md +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -25,9 +25,11 @@ IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 ## 当前接入 - 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。 -- 客户启停写 `IamTenantStatusChanged`。 +- 客户创建、审核结果及启停写 `IamTenantStatusChanged`。 - 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。 +普通 Admin/Client 的即时失效只覆盖用户停用、客户停用和权限版本推进。`token_version` 不进入 V1 投影,也不传给 Lastleg 校验;其即时权威比对只用于海外 PDA 向 Auth 核验仓绑定与会话版本。 + ## 尾程权限 `lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`: diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java index 4adf81539..c68eac775 100644 --- a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -7,7 +7,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; -@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient", + url = "${baiyue.security-event.event-bridge-url:}") public interface SecurityEventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index a2a9088a2..cc35971c5 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -104,6 +104,8 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); return response(tenantId, membership, user); } catch (RuntimeException exception) { try { diff --git a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java index 9e71f4848..a393d5cac 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MerchantOnboardingService.java @@ -42,6 +42,7 @@ public class MerchantOnboardingService { @Resource private IamTenantReviewMapper reviewMapper; @Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @Resource private SecurityEventOutboxService securityEvents; @Transactional public MerchantRegistrationResponse register(MerchantRegistrationRequest request) { @@ -78,6 +79,7 @@ public class MerchantOnboardingService { tenant.setDeleted(false); tenant.setVersion(0); tenantMapper.insert(tenant); + securityEvents.tenantStatus(tenantId, tenant.getStatus()); IamUser user = new IamUser(); user.setId(userId); @@ -109,6 +111,8 @@ public class MerchantOnboardingService { membershipRole.setTenantId(tenantId); membershipRole.setCreatedAt(LocalDateTime.now()); membershipRoleMapper.insert(membershipRole); + securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); if (StringUtils.hasText(request.address())) { IamTenantAddress address = new IamTenantAddress(); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index f0285949a..baa3d4658 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -77,6 +77,7 @@ public class PlatformInternalUserService { repository.insertUser(user); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, request.username(), roleIds); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); @@ -196,9 +197,16 @@ public class PlatformInternalUserService { IamMembership membership = newMembership(membershipId, user.getId(), memberNo); repository.insert(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + publishInitialSecurityProjection(user); return response(membership, user, authAccount.username(), roleIds); } + private void publishInitialSecurityProjection(IamUser user) { + securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "INITIAL_AUTHORIZATION"); + } + private InternalUserResponse response(IamMembership membership) { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 296100ffb..b7e719747 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -106,6 +106,8 @@ public class PlatformUserManagementService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); members.insert(membership); members.replaceRoles(tenantId, membershipId, roleIds); + securityEvents.userStatus(authUserId, tenantId, appCode, request.status()); + securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION"); return response(membership); } catch (RuntimeException localFailure) { compensateCreate(authUserId, localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java index 32b9bd9e4..de41c5d9d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -2,6 +2,7 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.common.event.RegionCode; import com.baiyue.iam.client.SecurityEventBridgeClient; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -45,6 +46,9 @@ public class SecurityEventOutboxRelay { try { EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class); EventEnvelopeValidator.requireValid(envelope); + if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS"); + } var response = client.receive(internalToken, envelope); if (response == null || !response.successful() || response.data() == null || !eventId.equals(response.data().eventId())) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java index fcc158420..520689a86 100644 --- a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -32,8 +32,9 @@ public class SecurityEventOutboxService { + "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId); long version = jdbc.queryForObject( "SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId); + String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED"; write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version, - new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now())); + new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now())); } public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) { diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java index 7eb7d502b..6e1a6adbc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java @@ -43,6 +43,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni private IamRoleMapper roles; @Resource private AuthAccountClient authClient; + @Resource + private SecurityEventOutboxService securityEvents; @Value("${baiyue.security.internal-service-token}") private String internalToken; private SecureRandom random = new SecureRandom(); @@ -112,6 +114,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni membership.setUpdatedBy(operatorId); members.insert(membership); members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId())); + publishInitialSecurityProjection(authUserId, tenant.getId()); return result; } catch (RuntimeException localFailure) { compensate(context, result); @@ -152,6 +155,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni String password = temporaryPassword(); String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password, tenant, owner.getId()); + publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId()); return ProvisioningResult.created(user.getAuthUserId(), username, password); } if (!"ACTIVE".equals(account.status()) @@ -166,6 +170,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni List repairedRoleIds = new java.util.ArrayList<>(roleIds); repairedRoleIds.add(ownerRole.getId()); members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList()); + securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } return ProvisioningResult.existing(); } @@ -230,6 +236,11 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni } } + private void publishInitialSecurityProjection(long authUserId, long tenantId) { + securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE"); + securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + private void compensate(long authUserId) { try { ApiResponse response = authClient.compensate(internalToken, authUserId); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index f29f58f63..f93c268cc 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -190,6 +190,7 @@ public class TenantService { tenant.setCreatedBy(operatorId); tenant.setUpdatedBy(operatorId); repository.insertTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); dimensionRuleInitialization.initialize(tenant.getId(), operatorId); overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName()); var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId); @@ -297,6 +298,7 @@ public class TenantService { tenant.setApprovedAt(approve ? LocalDateTime.now() : null); tenant.setApprovedBy(approve ? reviewerId : null); repository.saveTenant(tenant); + securityEvents.tenantStatus(tenant.getId(), tenant.getStatus()); for (var qualification : profileRepository.findQualifications(tenant.getId())) { if ("PENDING".equals(qualification.getStatus())) { qualification.setStatus(approve ? "APPROVED" : "REJECTED"); diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index e724fd6a4..c7f0fe556 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -23,6 +23,8 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue: + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -45,3 +47,4 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" + diff --git a/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java new file mode 100644 index 000000000..92692f92d --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/client/SecurityEventBridgeClientContractTest.java @@ -0,0 +1,35 @@ +package com.baiyue.iam.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SecurityEventBridgeClientContractTest { + private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize(); + + @Test + void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() { + FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class); + + assertThat(annotation.name()).isEqualTo("event-bridge-service"); + assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}"); + } + + @Test + void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception { + String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml")); + String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml")); + String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml")); + + assertThat(application).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(nacos).contains( + "event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}"); + assertThat(compose).contains( + "SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index e653c4c01..b69d85274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.iam.repository.MemberRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -135,6 +136,27 @@ class MemberServiceTest { verify(repository, never()).insert(any()); } + @Test void createPublishesActiveUserAndInitialAuthorizationAfterRoleAssignment() { + IamRole role = activeMerchantRole(50L, 100L); + when(roleMapper.selectById(50L)).thenReturn(role); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "member02", "成员", "ACTIVE"))); + when(repository.findRoleIds(eq(100L), anyLong())).thenReturn(List.of(50L)); + CreateMemberRequest request = new CreateMemberRequest("member02", "Password1", "成员", + null, null, "EMPLOYEE", "M-002", List.of(50L)); + + service.create(100L, request, Set.of("merchant:member:create", "merchant:member:grant-role")); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L))); + verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION"); + } + @Test void shouldHandleCase08() { IamRole role = activeMerchantRole(50L, 100L); when(roleMapper.selectById(50L)).thenReturn(role); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index ade730ea0..51159c7d7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -41,6 +41,7 @@ class MerchantOnboardingServiceTest { @MockBean private IamTenantReviewMapper reviews; @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -67,6 +68,10 @@ class MerchantOnboardingServiceTest { verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); @@ -194,3 +199,4 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } + diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index adaccd1b1..6e8da47c5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -85,6 +85,39 @@ class PlatformInternalUserServiceTest { verify(repository).insertUser(any(IamUser.class)); verify(repository).insert(any(IamMembership.class)); verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + long authUserId = captor.getValue().accountId(); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + + @Test + void linkingExistingAccountPublishesInitialProjectionOnlyForNewMembership() { + IamRole role = new IamRole(); + role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE"); + when(roleMapper.selectById(40L)).thenReturn(role); + when(repository.findUserByAuthId(10L)).thenReturn(user); + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(null); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-LINK", List.of(40L)); + + service.create(request); + + verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L))); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + verify(auth, never()).create(anyString(), any()); + } + + @Test + void relinkingExistingMembershipDoesNotDuplicateInitialProjection() { + when(repository.findByTenantAndAuthUser(0L, 10L)).thenReturn(membership); + CreateInternalUserRequest request = new CreateInternalUserRequest(10L, null, "平台运营", + "13800138000", null, null, null, "PLATFORM-30", List.of()); + + service.create(request); + + verifyNoInteractions(securityEvents); + verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList()); } @Test 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 4dd7ddfb6..fb4949914 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -12,6 +12,7 @@ import com.baiyue.iam.mapper.IamRoleMapper; import com.baiyue.iam.repository.MemberRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; @@ -20,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import java.time.LocalDateTime; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -85,6 +87,30 @@ class PlatformUserManagementServiceTest { verify(auth, never()).create(anyString(), any()); } + @Test + void createPublishesActualStatusAndInitialAuthorizationAfterRolesAreStored() { + AtomicReference createdUser = new AtomicReference<>(); + doAnswer(invocation -> { createdUser.set(invocation.getArgument(0)); return null; }) + .when(members).insertUser(any(IamUser.class)); + when(members.findUser(anyLong())).thenAnswer(invocation -> createdUser.get()); + when(members.findRoleIds(eq(0L), anyLong())).thenReturn(List.of(40001L)); + when(auth.create(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary(eq("internal-token"), anyLong())).thenAnswer(invocation -> ApiResponse.success( + new AuthAccountClient.AccountSummary(invocation.getArgument(1), "newuser", "新用户", "DISABLED"))); + CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000", + "new@example.com", 0L, List.of(40001L), "Password1", "DISABLED"); + + service.create(request); + + ArgumentCaptor account = + ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class); + verify(auth).create(eq("internal-token"), account.capture()); + long authUserId = account.getValue().accountId(); + verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L))); + verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION"); + } + @Test void optionsContainOnlyPlatformTenantAndPlatformRoles() { UserOptions result = service.options(); diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java new file mode 100644 index 000000000..aa212d14b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxRelayTest.java @@ -0,0 +1,91 @@ +package com.baiyue.iam.service; + +import com.baiyue.common.core.api.ApiResponse; +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.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.iam.client.SecurityEventBridgeClient; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityEventOutboxRelayTest { + @Mock private JdbcTemplate jdbc; + @Mock private SecurityEventBridgeClient client; + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + private SecurityEventOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new SecurityEventOutboxRelay(); + ReflectionTestUtils.setField(relay, "jdbc", jdbc); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); + ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + } + + @Test + void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception { + EventEnvelope envelope = envelope(RegionCode.OVERSEAS); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z"))); + + relay.deliver(envelope.eventId()); + + ArgumentCaptor sent = ArgumentCaptor.forClass(EventEnvelope.class); + verify(client).receive(eq("internal-token"), sent.capture()); + assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN); + assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS); + verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString()); + } + + @Test + void nonOverseasTargetNeverReachesTheBridge() throws Exception { + EventEnvelope envelope = envelope(RegionCode.CN); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1); + when(jdbc.queryForObject(anyString(), eq(String.class), any(), any())) + .thenReturn(objectMapper.writeValueAsString(envelope)); + when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0); + + relay.deliver(envelope.eventId()); + + verify(client, never()).receive(anyString(), any(EventEnvelope.class)); + verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(), + eq(envelope.eventId()), anyString()); + } + + private EventEnvelope envelope(RegionCode target) { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000081", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java index 432587f3f..fb1ce9e8b 100644 --- a/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/SecurityEventOutboxServiceTest.java @@ -1,10 +1,13 @@ package com.baiyue.iam.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.IamSecurityEventsV1; 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -43,4 +46,29 @@ class SecurityEventOutboxServiceTest { EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId()); } + + @ParameterizedTest + @CsvSource({ + "ACTIVE,ACTIVE", + "PROFILE_REQUIRED,DISABLED", + "PENDING,DISABLED", + "REJECTED,DISABLED", + "DISABLED,DISABLED", + "ARCHIVED,DISABLED" + }) + void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus, + String expectedSecurityStatus) throws Exception { + when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L); + + service.tenantStatus(10001L, businessStatus); + + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) " + + "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture()); + EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class); + IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue( + envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + assertThat(payload.status()).isEqualTo(expectedSecurityStatus); + assertThat(envelope.aggregateVersion()).isEqualTo(3L); + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java index 8dfbe466c..8e76d8a1d 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantAdministratorProvisioningServiceTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -43,6 +44,7 @@ class TenantAdministratorProvisioningServiceTest { @MockBean private MemberRepository members; @MockBean private IamRoleMapper roles; @MockBean private AuthAccountClient auth; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private TenantAdministratorProvisioningService service; private IamTenant tenant; @@ -101,6 +103,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members).insertUser(any(IamUser.class)); verify(members).insert(any(IamMembership.class)); verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L))); + verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test @@ -135,6 +140,7 @@ class TenantAdministratorProvisioningServiceTest { assertEquals(null, result.temporaryPassword()); verify(auth, never()).create(anyString(), any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verifyNoInteractions(securityEvents); } @Test @@ -153,6 +159,8 @@ class TenantAdministratorProvisioningServiceTest { assertFalse(result.created()); verify(auth, never()).create(anyString(), any()); verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L)); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "MEMBER_ROLES_CHANGED"); } @Test @@ -190,6 +198,9 @@ class TenantAdministratorProvisioningServiceTest { verify(members, never()).insertUser(any()); verify(members, never()).insert(any()); verify(members, never()).replaceRoles(anyLong(), anyLong(), any()); + verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); } @Test diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index ca4291de3..c9f7f82c6 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -320,6 +320,7 @@ class TenantServiceTest { verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @@ -428,6 +429,7 @@ class TenantServiceTest { assertEquals("ACTIVE", tenant.getStatus()); verify(repository).saveReview(review); verify(repository).saveTenant(tenant); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -527,3 +529,4 @@ class TenantServiceTest { return review; } } + diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index cc6a8ccc8..828e0e050 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -20,6 +20,7 @@ | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | +| IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | @@ -62,6 +63,7 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | +| 海外安全投影 | `ll_security_event_inbox`、`ll_security_projection` | IAM 安全事件可靠收件箱与 Lastleg 本地授权投影;只允许内部服务入口访问,不作为 loading、shipping、inbound 等业务模型 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。 - `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。 @@ -76,6 +78,9 @@ - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 +- Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 +- 开发基线只为国内固定 `authUserId=1000001`、`tenantId=0` 的 `platform_admin` 写 USER/AUTHORIZATION version=0 投影;使用 `INSERT IGNORE`,后续 IAM version>=1 事件单调覆盖。不得扩大为历史账号运行时补齐或缺失投影默认放行。 - PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 - 海外 PDA 柜号签收只允许平台 `WAREHOUSE_PICKER` 使用 `lastleg:pda:operate`;PDA 不接收 `customerId`,候选跨客户读取,选柜时从柜内计划唯一解析并冻结真实客户。未完成会话按平台操作账号和柜号分别全局唯一,后续照片、扫码、实收、库存与完成操作只使用会话客户范围;平台管理员和客户账号不得进入该作业入口。 @@ -94,6 +99,7 @@ ## 修改与验证 - 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 安全投影边界变更同时运行 `SecurityProjectionArchitectureTest` 与 `LastlegSecurityContractTest`;`security/controller|dto|service` 只允许依赖本子域和根 `InternalServiceTokenGuard`,其他 Lastleg 业务包不得直接依赖这些投影内部包。 - 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 - 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。 - 定向验证:`mvn -pl lastleg -am test`。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 938233013..d1274b7a8 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -19,6 +19,7 @@ org.springframework.bootspring-boot-starter-actuator com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest + com.tngtech.archunitarchunittest org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index c639c1bcd..a2488d164 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -12,6 +12,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], ['relabel-transfer-discard', /(?:^|\.)(?:relabel|transfer|discard)(?:\.|$)/], ['inbound-pda', /(?:^|\.)(?:inboundplan|palletops|pda)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index bb8a6a945..28469687c 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -74,3 +74,25 @@ test('vehicle application only depends inward and stays free of adapter framewor && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); assert.deepEqual(outwardDependencies, []); }); + +test('security projection stays isolated from Lastleg business slices', () => { + const report = analyze(); + const projectionClasses = report.classes.filter(entry => entry.slice === 'security-projection'); + const projectionNames = new Set(projectionClasses.map(entry => entry.fqcn)); + assert.deepEqual(projectionClasses.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.security.controller.LastlegSecurityController', + 'com.baiyue.lastleg.security.dto.LastlegSecurityDtos', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror', + 'com.baiyue.lastleg.security.service.LastlegSecurityProjectionService', + 'com.baiyue.lastleg.security.service.LastlegSecurityValidationService', + 'com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity', + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'security-projection'), [ + { source: 'security-projection', target: 'shared-technical', count: 1 }, + ]); + assert.deepEqual(report.sliceEdges.filter(edge => edge.target === 'security-projection'), []); + assert.equal(report.sliceSccs.some(component => component.includes('security-projection')), false); + const businessConsumers = report.classDependencies.filter(edge => projectionNames.has(edge.target) + && !projectionNames.has(edge.source)); + assert.deepEqual(businessConsumers, []); +}); diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java similarity index 60% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java index 05fe60b65..6b3cb5093 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java @@ -1,13 +1,13 @@ -package com.baiyue.overseassecurity.controller; +package com.baiyue.lastleg.security.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; -import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; -import com.baiyue.overseassecurity.service.SecurityProjectionService; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService; +import com.baiyue.lastleg.security.service.LastlegSecurityValidationService; import io.swagger.v3.oas.annotations.Hidden; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.PostMapping; @@ -20,14 +20,15 @@ import java.time.Instant; @Hidden @RestController -@RequestMapping("/internal/overseas-security/v1") -public class OverseasSecurityController { +@RequestMapping("/internal/lastleg/security/v1") +public class LastlegSecurityController { private final InternalServiceTokenGuard tokenGuard; - private final SecurityProjectionService projections; - private final OverseasSecurityValidationService validations; + private final LastlegSecurityProjectionService projections; + private final LastlegSecurityValidationService validations; - public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, - OverseasSecurityValidationService validations) { + public LastlegSecurityController(InternalServiceTokenGuard tokenGuard, + LastlegSecurityProjectionService projections, + LastlegSecurityValidationService validations) { this.tokenGuard = tokenGuard; this.projections = projections; this.validations = validations; diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java similarity index 74% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java index 7560da9b2..cb301fd7f 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java @@ -1,26 +1,29 @@ -package com.baiyue.overseassecurity.dto; +package com.baiyue.lastleg.security.dto; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import java.time.Instant; -public final class OverseasSecurityDtos { - private OverseasSecurityDtos() { } +public final class LastlegSecurityDtos { + private LastlegSecurityDtos() { } public record Acknowledgement(String eventId, String result, Instant receivedAt) { } public record ValidationRequest(@NotNull @Min(1) Long userId, @NotNull @Min(0) Long tenantId, - @NotNull @Min(0) Long tokenVersion, @NotNull @Min(0) Long permissionVersion) { } public record ValidationResponse(boolean valid, FailureReason failureReason) { public static ValidationResponse accepted() { return new ValidationResponse(true, null); } - public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + public static ValidationResponse rejected(FailureReason reason) { + return new ValidationResponse(false, reason); + } } public enum FailureReason { + SECURITY_PROJECTION_MISSING, + SECURITY_PROJECTION_NOT_READY, USER_DISABLED, TENANT_DISABLED, PERMISSION_VERSION_STALE diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java index e022e01cb..5e888487b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java @@ -1,4 +1,4 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -11,19 +11,19 @@ import org.springframework.transaction.event.TransactionalEventListener; /** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ @Component @Slf4j -public class SecurityProjectionRedisMirror { +public class LastlegSecurityProjectionRedisMirror { private final StringRedisTemplate redis; private final ObjectMapper objectMapper; @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") private String keyPrefix; - public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { this.redis = redis; this.objectMapper = objectMapper; } @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void mirror(SecurityProjectionService.ProjectionChanged event) { + public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) { var value = event.projection(); try { redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java similarity index 66% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java index d76d77932..dcfe0ab4b 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java @@ -1,8 +1,10 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; 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.IamSecurityEventsV1; import com.baiyue.common.event.RegionCode; @@ -15,17 +17,19 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.List; +import java.util.Locale; import java.util.Objects; @Service -public class SecurityProjectionService { +public class LastlegSecurityProjectionService { private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final ApplicationEventPublisher events; - public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, + ApplicationEventPublisher events) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.events = events; @@ -35,35 +39,38 @@ public class SecurityProjectionService { public ConsumeResult consume(EventEnvelope envelope) { envelope.requireValid(); requireSecurityEnvelope(envelope); + String payloadHash = validatedStorageHash(envelope); String payloadJson = envelope.payload().toString(); try { - jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), - envelope.payloadHash(), payloadJson); + payloadHash, payloadJson); } catch (DuplicateKeyException duplicate) { - InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + InboxRow existing = jdbc.queryForObject( + "SELECT event_type,payload_hash,status FROM ll_security_event_inbox " + "WHERE event_id=? FOR UPDATE", (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), envelope.eventId()); if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) - || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + || existing.payloadHash() == null + || !existing.payloadHash().equalsIgnoreCase(payloadHash)) { throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); } if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); } - ProjectionInput input = input(envelope); + ProjectionInput input = input(envelope, payloadHash); ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); - ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide( current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), input.aggregateVersion(), input.payloadHash()); String result = decision.name(); - if (decision == ProjectionMonotonicity.Decision.APPLY) { + if (decision == SecurityProjectionMonotonicity.Decision.APPLY) { upsert(input); result = "APPLIED"; events.publishEvent(new ProjectionChanged(input)); } - jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + "WHERE event_id=?", result, envelope.eventId()); return new ConsumeResult(result, input); } @@ -75,31 +82,48 @@ public class SecurityProjectionService { } } - private ProjectionInput input(EventEnvelope envelope) { + static String validatedStorageHash(EventEnvelope envelope) { + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致"); + } + // 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。 + return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT); + } + + private ProjectionInput input(EventEnvelope envelope, String payloadHash) { try { return switch (envelope.eventType()) { case EventTypes.IAM_USER_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.UserStatusChanged.class); requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), - value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), - envelope.eventId(), value.changedAt()); + value.status(), value.permissionVersion(), envelope.aggregateVersion(), + payloadHash, envelope.eventId(), value.changedAt()); } case EventTypes.IAM_TENANT_STATUS_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.TenantStatusChanged.class); requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); - requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), + envelope.aggregateVersion()); + requireStatus(value.status()); yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), - value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), - value.changedAt()); + value.status(), null, envelope.aggregateVersion(), payloadHash, + envelope.eventId(), value.changedAt()); } case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { - var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + var value = objectMapper.treeToValue(envelope.payload(), + IamSecurityEventsV1.PermissionVersionChanged.class); requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); - requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), + envelope.aggregateVersion()); yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), - null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash, envelope.eventId(), value.changedAt()); } default -> throw new IllegalArgumentException("unsupported type"); @@ -111,7 +135,8 @@ public class SecurityProjectionService { } } - private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + private static void requireIdentity(EventEnvelope envelope, String aggregateType, + long aggregateId, long tenantId) { if (!aggregateType.equals(envelope.aggregateType()) || !String.valueOf(aggregateId).equals(envelope.aggregateId()) || !String.valueOf(tenantId).equals(envelope.tenantId())) { @@ -119,28 +144,38 @@ public class SecurityProjectionService { } } - private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { - if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + private static void requirePayload(String requiredText, Instant changedAt, + long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null + || payloadVersion != aggregateVersion) { throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); } } + private static void requireStatus(String status) { + if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED"); + } + } + private ProjectionRow findForUpdate(String type, String id) { - List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + List rows = jdbc.query( + "SELECT aggregate_version,payload_hash FROM ll_security_projection " + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); return rows.isEmpty() ? null : rows.get(0); } private void upsert(ProjectionInput value) { - jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", - value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), - value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), + value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(), + value.changedAt()); } private record InboxRow(String eventType, String payloadHash, String status) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java new file mode 100644 index 000000000..b23605379 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class LastlegSecurityValidationService { + private final JdbcTemplate jdbc; + + public LastlegSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId()); + if (userStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + if (request.tenantId() > 0) { + String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId()); + if (tenantStatus == null) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + if (!"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + } + List projectedVersions = jdbc.query( + "SELECT MAX(permission_version) FROM ll_security_projection " + + "WHERE aggregate_id=? AND tenant_id=? " + + "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0", + (rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId()); + if (projectedVersions.isEmpty()) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING); + } + long projectedVersion = projectedVersions.get(0); + if (request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + if (request.permissionVersion() > projectedVersion) { + return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id, long tenantId) { + List values = jdbc.query("SELECT security_status FROM ll_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?", + (rs, row) -> rs.getString(1), type, id, tenantId); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java similarity index 82% rename from overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java index 958e5e44b..4085bc4de 100644 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java @@ -1,12 +1,12 @@ -package com.baiyue.overseassecurity.service; +package com.baiyue.lastleg.security.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import java.util.Objects; -final class ProjectionMonotonicity { - private ProjectionMonotonicity() { } +final class SecurityProjectionMonotonicity { + private SecurityProjectionMonotonicity() { } static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 3699c27ba..9a76d798a 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -70,7 +70,7 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks session-mode: claims issuer: ${JWT_ISSUER:auth-service} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 893cea457..44d82c7d3 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1624,3 +1624,42 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_operation ( 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='本土散板订单幂等操作账'; + +CREATE TABLE IF NOT EXISTS ll_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希', + payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON', + status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '处理完成时间', + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱'; + +CREATE TABLE IF NOT EXISTS ll_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (aggregate_type,aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 +INSERT IGNORE INTO ll_security_projection ( + aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version, + payload_hash,last_event_id,changed_at,updated_at +) VALUES + ('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256), + 'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'), + ('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256), + 'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java index 49b0a41a3..400699222 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java @@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest { assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value()); assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value()); } + + @Test void missingServerConfigurationFailsClosed() { + InternalServiceTokenGuard guard = new InternalServiceTokenGuard(""); + assertEquals(503, assertThrows(ResponseStatusException.class, + () -> guard.verify("any-token")).getStatusCode().value()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java new file mode 100644 index 000000000..a004dd8d4 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/LastlegSecurityContractTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.security; + +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 LastlegSecurityContractTest { + @Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception { + String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql"); + String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql"); + String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql"); + String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java"); + String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java"); + String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java"); + String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java"); + assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox", + "PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "CREATE TABLE IF NOT EXISTS ll_security_projection", + "PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT", + "permission_version BIGINT"); + for (String sql : new String[]{baseline, migration}) { + assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0", + "('AUTHORIZATION','1000001',0,NULL,0,0", + "baseline-platform-admin-user-v0", + "baseline-platform-admin-authorization-v0") + .doesNotContain("baseline-merchant-admin"); + } + assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id", + "default_app_code"); + assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'") + .doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security", + "USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox", + "CREATE TABLE IF NOT EXISTS overseas_security_projection"); + assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash") + .doesNotContain("LoadingCompletedConsumer"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?", + "SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion") + .doesNotContain("Long tokenVersion"); + } + + @Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED", + "FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型") + .doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED"); + } + + @Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception { + String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java"); + String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java"); + String application = read("lastleg-service/src/main/resources/application.yml"); + assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")", + "@PostMapping(\"/events\")", "@PostMapping(\"/validate\")", + "com.baiyue.lastleg.security.InternalServiceTokenGuard") + .doesNotContain("/internal/overseas-security/v1"); + assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED"); + assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}") + .doesNotContain("phase1-local-internal-token"); + } + + 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/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java new file mode 100644 index 000000000..27a8de243 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.security; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +class SecurityProjectionArchitectureTest { + private static final String LASTLEG = "com.baiyue.lastleg.."; + private static final String CONTROLLER = "com.baiyue.lastleg.security.controller.."; + private static final String DTO = "com.baiyue.lastleg.security.dto.."; + private static final String SERVICE = "com.baiyue.lastleg.security.service.."; + + private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.baiyue.lastleg"); + + @Test + void projectionInternalsDoNotDependOnLastlegBusinessSlices() { + classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .should().onlyDependOnClassesThat( + resideOutsideOfPackage(LASTLEG) + .or(resideInAPackage(CONTROLLER)) + .or(resideInAPackage(DTO)) + .or(resideInAPackage(SERVICE)) + .or(resideInAPackage("com.baiyue.lastleg.security"))) + .check(PRODUCTION_CLASSES); + } + + @Test + void businessSlicesDoNotDependOnProjectionInternals() { + noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE) + .should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE) + .check(PRODUCTION_CLASSES); + } + + @Test + void dtoAndServicesStayIndependentFromInternalWebAdapter() { + noClasses().that().resideInAnyPackage(DTO, SERVICE) + .should().dependOnClassesThat().resideInAPackage(CONTROLLER) + .check(PRODUCTION_CLASSES); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java new file mode 100644 index 000000000..39d78d387 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionTransportTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +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.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegSecurityProjectionTransportTest { + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @Test + void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() { + EventEnvelope envelope = envelope(); + + String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope); + + assertThat(stored).hasSize(64).doesNotStartWith("sha256:"); + assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored); + } + + @Test + void rejectsTamperedPayloadBeforeInboxMutation() { + EventEnvelope source = envelope(); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered)) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash"); + } + + private EventEnvelope envelope() { + var value = new IamSecurityEventsV1.PermissionVersionChanged( + 101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z")); + var payload = objectMapper.valueToTree(value); + return new EventEnvelope("00000000-0000-4000-8000-000000000091", + EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION, + Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0", + "AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678", + null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java new file mode 100644 index 000000000..5b934e0c6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason; +import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +class LastlegSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc); + + @Test void disabledUserFailsClosed() { + doReturn(List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void disabledCustomerFailsClosed() { + doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.TENANT_DISABLED); + } + + @Test void missingUserProjectionFailsClosed() { + doReturn(List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } + + @Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() { + doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue(); + } + + @Test void permissionVersionMustExactlyMatchReliableProjection() { + doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L), + List.of("ACTIVE"), List.of(9L)).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue(); + } + + @Test void missingPermissionProjectionFailsClosed() { + doReturn(List.of("ACTIVE"), List.of()).when(jdbc) + .query(anyString(), any(RowMapper.class), any(Object[].class)); + assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason()) + .isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java new file mode 100644 index 000000000..b385e2aea --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.security.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SecurityProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.STALE); + assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index cc5660ecb..2966a26c2 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -23,6 +23,7 @@ "relabel-transfer-discard->inventory", "relabel-transfer-discard->shared-technical", "relabel-transfer-discard->tailleg-handoff", + "security-projection->shared-technical", "tailleg-handoff->dropship", "tailleg-handoff->inbound-pda", "tailleg-handoff->inventory", @@ -35,7 +36,13 @@ "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", - "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", + "com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java", + "com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java", + "com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java", + "com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java", + "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java" }, "applicationDependencyRule": { "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", @@ -49,6 +56,10 @@ ] }, "sliceSpecificRules": { + "security-projection": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": [] + }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], "allowedIncoming": ["procurement-trucking-looseboard"] diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md deleted file mode 100644 index 01cb5953f..000000000 --- a/overseas-security-service/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Overseas Security Service 模块指引 - -## 职责边界 - -- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 -- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 -- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 - -## 入口 - -- 内部事件与校验:`controller/OverseasSecurityController.java` -- 幂等、单调投影:`service/SecurityProjectionService.java` -- Token 校验:`service/OverseasSecurityValidationService.java` -- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml deleted file mode 100644 index a8c5cd7fd..000000000 --- a/overseas-security-service/pom.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - 4.0.0 - com.baiyue.firstlegbaiyue-firstleg-platform - 1.0.0-SNAPSHOT../pom.xml - overseas-security-service - - com.baiyue.sharedbaiyue-common-core${project.version} - com.baiyue.sharedbaiyue-common-web${project.version} - com.baiyue.sharedbaiyue-common-openapi${project.version} - com.baiyue.sharedcommon-event-contract${project.version} - org.springframework.bootspring-boot-starter-jdbc - org.springframework.bootspring-boot-starter-data-redis - com.mysqlmysql-connector-jruntime - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery - com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config - org.springframework.bootspring-boot-starter-actuator - org.springframework.bootspring-boot-starter-testtest - - org.springframework.bootspring-boot-maven-plugin - diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java deleted file mode 100644 index cfe7c82a6..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.baiyue.overseassecurity; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class OverseasSecurityApplication { - public static void main(String[] args) { - SpringApplication.run(OverseasSecurityApplication.class, args); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java deleted file mode 100644 index 1a6aa6b7c..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.overseassecurity.security; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; - -@Component -public class InternalServiceTokenGuard { - @Value("${baiyue.security.internal-service-token:}") private String configuredToken; - private byte[] expected; - - @PostConstruct void initialize() { - expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); - if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); - } - - public void verify(String supplied) { - byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); - } -} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java deleted file mode 100644 index 4b7ee66cf..000000000 --- a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class OverseasSecurityValidationService { - private final JdbcTemplate jdbc; - - public OverseasSecurityValidationService(JdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public ValidationResponse validate(ValidationRequest request) { - String userStatus = status("USER", String.valueOf(request.userId())); - if (userStatus != null && !"ACTIVE".equals(userStatus)) { - return ValidationResponse.rejected(FailureReason.USER_DISABLED); - } - String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); - if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { - return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); - } - Long projectedVersion = jdbc.queryForObject( - "SELECT MAX(permission_version) FROM overseas_security_projection " - + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", - Long.class, String.valueOf(request.userId())); - if (projectedVersion != null && request.permissionVersion() < projectedVersion) { - return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); - } - return ValidationResponse.accepted(); - } - - private String status(String type, String id) { - List values = jdbc.query("SELECT security_status FROM overseas_security_projection " - + "WHERE aggregate_type=? AND aggregate_id=?", - (rs, row) -> rs.getString(1), type, id); - return values.isEmpty() ? null : values.get(0); - } -} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml deleted file mode 100644 index 6817568d2..000000000 --- a/overseas-security-service/src/main/resources/application.yml +++ /dev/null @@ -1,28 +0,0 @@ -server: - port: ${OVERSEAS_SECURITY_PORT:9307} -spring: - application.name: overseas-security-service - config.import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${OVERSEAS_SECURITY_DB_URL} - username: ${OVERSEAS_SECURITY_DB_USERNAME} - password: ${OVERSEAS_SECURITY_DB_PASSWORD} - data.redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - cloud.nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} -baiyue.security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} -management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql deleted file mode 100644 index 7553fba93..000000000 --- a/overseas-security-service/src/main/resources/db/overseas-security.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; -USE baiyue_overseas_security; - -CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( - event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', - event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', - payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', - payload_json JSON NOT NULL COMMENT '冻结事件Payload', - status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', - result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', - received_at DATETIME(3) NOT NULL, - processed_at DATETIME(3) NULL, - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type, received_at) -) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; - -CREATE TABLE IF NOT EXISTS overseas_security_projection ( - aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', - aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', - tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', - security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', - permission_version BIGINT NULL COMMENT '当前单调权限版本', - aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', - payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', - last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', - changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at DATETIME(3) NOT NULL, - PRIMARY KEY (aggregate_type, aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id, permission_version) -) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml deleted file mode 100644 index e228e9386..000000000 --- a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml +++ /dev/null @@ -1,26 +0,0 @@ -openapi: 3.0.3 -info: - title: Overseas Security Projection Internal API - version: 1.0.0 -paths: - /internal/overseas-security/v1/events: - post: - summary: Consume one IAM security EventEnvelope - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "Acknowledgement contains eventId, result and receivedAt" - /internal/overseas-security/v1/validate: - post: - summary: Validate locally verified overseas JWT claim versions - description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. - security: [{ internalServiceToken: [] }] - responses: - '200': - description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" -components: - securitySchemes: - internalServiceToken: - type: apiKey - in: header - name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java deleted file mode 100644 index b626bb89b..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.baiyue.overseassecurity; - -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 OverseasSecurityContractTest { - @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { - String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); - String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); - String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); - String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); - assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", - "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); - assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); - assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); - assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", - "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); - } - - @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { - String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); - String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); - String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); - assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", - "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); - assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); - assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") - .doesNotContain("IamSecurityEventsV1"); - assertThat(lastleg).contains("Lastleg 不消费该事件类型") - .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); - } - - @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { - String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); - assertThat(openapi).contains("/internal/overseas-security/v1/events:", - "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", - "userId, tenantId, tokenVersion and permissionVersion"); - assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); - } - - 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/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java deleted file mode 100644 index f38834f99..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; -import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSecurityValidationServiceTest { - private final JdbcTemplate jdbc = mock(JdbcTemplate.class); - private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); - - @Test void disabledUserAndTenantFailClosed() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of("DISABLED")); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.USER_DISABLED); - } - - @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { - when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) - .thenReturn(List.of(), List.of()); - when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) - .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); - assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); - } -} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java deleted file mode 100644 index 89560963c..000000000 --- a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.overseassecurity.service; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ProjectionMonotonicityTest { - @Test void appliesFirstAndNewerVersions() { - assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.APPLY); - } - - @Test void ignoresOlderAndSameHashVersions() { - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) - .isEqualTo(ProjectionMonotonicity.Decision.STALE); - assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) - .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); - } - - @Test void rejectsSameVersionWithDifferentHash() { - assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) - .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); - } -} diff --git a/pom.xml b/pom.xml index 2cd812fb5..20134475d 100644 --- a/pom.xml +++ b/pom.xml @@ -39,7 +39,6 @@ common-event-contract gateway gateway-overseas - overseas-security-service auth iam order diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 49f40a477..6bb27b5ea 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -350,7 +350,9 @@ export class AcceptanceRun { this.coverageGaps = [] this.context = {} this.token = '' + this.refreshToken = '' this.overseasToken = '' + this.overseasExpiresAt = 0 this.fatal = null } @@ -553,6 +555,30 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + async refreshSession(reason) { + const response = await this.request('POST', '/api/auth/refresh', + { refreshToken: this.refreshToken }, '') + const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response, + `reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) + const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) + && Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0 + this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid, + `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + if (!valid) return false + const replacement = { + token: next.accessToken, + refreshToken: next.refreshToken, + overseasToken: next.overseasAccessToken, + overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000, + } + this.token = replacement.token + this.refreshToken = replacement.refreshToken + this.overseasToken = replacement.overseasToken + this.overseasExpiresAt = replacement.overseasExpiresAt + return true + } + async findAvailablePhone(module = 'customers') { const seed = BigInt(Date.now() % 10_000_000_000) for (let attempt = 0; attempt < 20; attempt += 1) { @@ -1159,7 +1185,10 @@ export class AcceptanceRun { if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken + this.refreshToken = login.refreshToken || '' this.overseasToken = login.overseasAccessToken || '' + this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 + ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-')) if (requiresOverseas) { this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, @@ -1170,6 +1199,10 @@ export class AcceptanceRun { const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] + if (module.startsWith('phase4-overseas-') + && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { + await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) + } const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name)) if (module === 'finance' && fullFinanceChain) { this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' }) @@ -1233,6 +1266,19 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) + .some((name) => name.startsWith('phase4-overseas-')) + if (module === 'system' && laterOverseasModule) { + const refreshed = await this.refreshSession('system模块权限与角色变更完成') + if (refreshed) { + const probe = await this.requestOverseas('GET', + '/api/platform/v1/last-leg/orders?page=1&size=1') + this.assert('authentication', 'system后新admin海外Token只读探针可用', probe, + probe.http === 200 && String(probe.body?.code) === '200', + `HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`, + { scenario: 'dual-token-refresh', deploymentBlocker: true }) + } + } } } const reportSurface = JSON.stringify({ @@ -1240,7 +1286,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.overseasToken].filter((value) => typeof value === 'string' && value.length >= 8) + const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, diff --git a/scripts/admin-test-env-modules/customer-system-contract.test.mjs b/scripts/admin-test-env-modules/customer-system-contract.test.mjs index 64527c588..80f236ad0 100644 --- a/scripts/admin-test-env-modules/customer-system-contract.test.mjs +++ b/scripts/admin-test-env-modules/customer-system-contract.test.mjs @@ -30,6 +30,16 @@ test('系统验收覆盖后端四种权限资源类型并清理测试资源', () assert.match(system, /DELETE.*permissions\/\$\{additionalPermissionId\}/s) }) +test('system后刷新双Token且受限海外权限只接受403语义', () => { + assert.match(acceptance, /module === 'system' && laterOverseasModule/) + assert.match(acceptance, /refreshSession\('system模块权限与角色变更完成'\)/) + assert.match(acceptance, /system后新admin海外Token只读探针可用/) + assert.match(acceptance, /Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(system, /缺少canonical海外订单权限必须403/) + assert.match(system, /denied\.http === 403 \|\| \['403', 'A0403'\]\.includes/) + assert.doesNotMatch(system, /A0401[^\r\n]{0,120}(必须403|includes)/) +}) + test('客户验收覆盖客户维度操作日志列表和详情快照', () => { assert.match(acceptance, /audit\/logs\?accountId=\$\{tenantId\}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=\$\{tenantId\}/) assert.match(acceptance, /PLATFORM_TENANT_UPDATE/) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 7ebbd6ded..bc11e7d09 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -28,6 +28,13 @@ function Assert-Contains([string]$text, [string]$needle, [string]$scope) { } } +function Assert-NotContains([string]$text, [string]$needle, [string]$scope) { + $script:checked++ + if ($text.Contains($needle)) { + Add-Failure "$scope 仍残留: $needle" + } +} + function Assert-Matches([string]$text, [string]$pattern, [string]$scope) { $script:checked++ if ($text -notmatch $pattern) { @@ -156,14 +163,14 @@ foreach ($gatewayPath in @( Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' -# 海外安全投影是独立部署单元,服务映射、海外编排和 Jenkins 构建入口必须同时存在。 +# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') $overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'overseas-security-service:' '服务部署映射' -Assert-Contains $overseasCompose 'overseas-security-service:' '海外 Compose' -Assert-Contains $overseasCompose '/internal/overseas-security/v1/events' '海外安全事件消费地址' -Assert-Contains $serviceJenkinsfile "'overseas-security-service'" '服务 Jenkins 构建入口' +Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' +Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' +Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' +Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw @@ -238,11 +245,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java', - 'baiyue-firstleg/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java', - 'baiyue-firstleg/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml', - 'baiyue-firstleg/overseas-security-service/src/main/resources/db/overseas-security.sql', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java', @@ -309,7 +314,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-security/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), - (Join-Path $backendRoot 'overseas-security-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') @@ -349,5 +353,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、海外安全服务部署映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8e3831751..8744660a1 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -58,9 +58,9 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) - assert.deepEqual(summary.topology, { modules: 23, codeModules: 22, services: 16 }) - assert.equal(summary.openapi.documents, 27) - assert.equal(summary.openapi.coveredServices.length, 8) + assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) + assert.equal(summary.openapi.documents, 26) + assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 6c094c6e3..31f96cf24 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { +test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 23) - assert.equal(new Set(modules).size, 23) - assert.equal(services.size, 16) + assert.equal(modules.length, 22) + assert.equal(new Set(modules).size, 22) + assert.equal(services.size, 15) const bootModules = [] for (const module of modules) { diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index b1f89decb..d5c3efac4 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,18 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) + assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) + assert.doesNotMatch(service, /overseas-security-service/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -32,12 +32,11 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.doesNotMatch(map, /overseas-security-service|9307/) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -50,7 +49,6 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -58,8 +56,9 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) - assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) + assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) + assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) + assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) @@ -72,7 +71,11 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as const cn = await read('config/nacos/cn/event-bridge-service.yml') const overseas = await read('config/nacos/overseas/event-bridge-service.yml') const firstleg = await read('config/nacos/cn/firstleg-service.yml') + const iam = await read('config/nacos/cn/iam-service.yml') + const iamApplication = await read('iam/src/main/resources/application.yml') + const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') const lastleg = await read('config/nacos/overseas/lastleg-service.yml') + const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) @@ -80,9 +83,17 @@ test('CN and overseas event bridges have explicit opposite-region consumers', as assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) + assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) + assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) + assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) + assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) + assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) + assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) + assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) assert.match(scripts, /docker-compose\.overseas\.yml/) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 6bfe64c05..03fdfdd11 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -44,10 +44,47 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.equal(options.gateway, 'http://localhost:9000') assert.equal(options.overseasGateway, 'http://localhost:9300') assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) + assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/) + assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/) + assert.match(source, /async refreshSession\(reason\)/) + assert.match(source, /refreshToken: this\.refreshToken/) + assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/) + assert.match(source, /module === 'system' && laterOverseasModule/) + assert.match(source, /system后新admin海外Token只读探针可用/) + assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) + assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/) assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) +}) + +test('dual-token refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 }) + run.request = async (method, path, body, token) => { + assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh') + assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '') + return { method, path, http: 200, body: { code: '200', data: { + accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas', + overseasExpiresIn: 900, + } }, requestId: 'refresh-request', traceId: 'refresh-trace' } + } + assert.equal(await run.refreshSession('contract'), true) + assert.equal(run.token, 'new-access') + assert.equal(run.refreshToken, 'new-refresh') + assert.equal(run.overseasToken, 'new-overseas') + assert.ok(run.overseasExpiresAt > Date.now()) + + const frozen = { token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + run.request = async (method, path) => ({ method, path, http: 200, + body: { code: '200', data: { accessToken: 'partial-only' } }, + requestId: 'partial-request', traceId: 'partial-trace' }) + assert.equal(await run.refreshSession('incomplete-contract'), false) + assert.deepEqual({ token: run.token, refreshToken: run.refreshToken, + overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen) }) test('entry has no built-in environment address or password and requires explicit execution options', () => { diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index baa57b467..e4c7ced35 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 8cea72284..6c69f4df7 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -121,9 +121,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 030af7e92..485ac8d42 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -13,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service', 'overseas-security-service' + 'warehouse-overseas-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) @@ -45,7 +45,6 @@ const expectedOnlineEdges = [ const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'event-bridge-service->overseas-security-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', @@ -131,6 +130,7 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) + test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], @@ -170,7 +170,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', 'gateway-overseas->auth-service', - 'gateway-overseas->overseas-security-service' + 'gateway-overseas->lastleg-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], @@ -185,10 +185,10 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ]]) }) -test('overseas security edges never become online business dependencies', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +test('overseas security projection stays in Lastleg infrastructure and background edges', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { @@ -242,3 +242,4 @@ test('shared Feign timeout defaults and long-running export overrides do not dri const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) }) + diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 4d324af00..2215862cb 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -57,6 +57,8 @@ test('Java builds use the shared builder pool and serialize incremental target d assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) assert.match(directDeployScript, /up -d --no-deps --pull never/) assert.doesNotMatch(directDeployScript, /--remove-orphans/) + assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) + assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) assert.match(directDeployScript, /--pull never/) }) From eb1a8895ac4eeda80a56479ee324381d22a7b8a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:50 +0800 Subject: [PATCH 186/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MerchantRegistrationServiceTest.java | 7 +- config/nacos/cn/iam-service.yml | 4 +- config/nacos/local/iam-service.yml | 3 + deploy/sql/头程数据库统一增量-MySQL8.sql | 37 +++++++---- docs/同步调用依赖与失败传播治理基线.md | 14 ++-- .../CustomerDimensionInitializationRelay.java | 2 + iam/src/main/resources/application.yml | 4 +- ...imensionInitializationSqlContractTest.java | 64 +++++++++++++++++++ ...tomerDimensionInitializationRelayTest.java | 7 +- .../MerchantOnboardingServiceTest.java | 5 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++++- ...astleg-internal-modularization-baseline.md | 29 ++++++--- scripts/README.md | 4 +- .../synchronous-dependency-contract.test.mjs | 14 +++- 14 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 5a4541699..fd61c5925 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest { void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); var result = service.register(new MerchantRegisterRequest( "registered_owner", "Password123", "注册人", "测试企业", "张三", @@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest { assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus()); assertEquals("encoded-password", storedAccount.getValue().getPasswordHash()); assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep()); + assertEquals("PENDING", result.dimensionInitializationStatus()); } @Test void failedAuthInsertCompensatesTheIamRegistration() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any()); @@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest { void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() { when(encoder.encode("Password123")).thenReturn("encoded-password"); when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success( - new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED"))); + new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING"))); when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null)); TransactionSynchronizationManager.initSynchronization(); diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 48a0d8795..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,8 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} - diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index 4639f7ca1..cc1ad14ab 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -4,6 +4,9 @@ spring.datasource: username: ${IAM_DB_USERNAME:root} password: ${IAM_DB_PASSWORD:123456} baiyue: + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ca30df085..22b4dabb6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14892,6 +14892,21 @@ BEGIN 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' AND table_name='iam_tenant' + AND column_name='dimension_initialization_status') <> 1 + OR (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task') <> 1 + OR (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema='baiyue_iam' + AND table_name='iam_customer_dimension_initialization_task' + AND column_name IN ('tenant_id','initialization_key','action','status','attempt_count', + 'next_retry_at','lease_owner','lease_until','last_error_code','last_error_message', + 'task_version')) <> 11 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:IAM客户默认尺寸后台初始化状态或可靠任务结构不完整'; + END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query' AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at', @@ -16579,17 +16594,17 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task ( initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}', action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿', status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED', - attempt_count INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - lease_owner VARCHAR(128) NULL, - lease_until DATETIME(3) NULL, - last_error_code VARCHAR(32) NULL, - last_error_message VARCHAR(1000) NULL, - remote_config_id BIGINT NULL, - task_version INT NOT NULL DEFAULT 0, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - completed_at DATETIME(3) NULL, + attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间', + lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者', + lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间', + last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码', + last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证', + remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID', + task_version INT 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 '任务更新时间', + completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间', PRIMARY KEY (tenant_id), KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until), KEY idx_customer_dimension_task_diagnosis (status,updated_at) diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index cf7410e43..b9c4dde5f 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -2,7 +2,7 @@ ## 目的与口径 -本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 +本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。 “存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分: @@ -21,7 +21,7 @@ |---|---|---:|---| | Auth | IAM | 2 | 登录授权、注册,`REQUEST` | | IAM | Auth | 1 | 账号开通/管理,`REQUEST` | -| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` | +| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` | | IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` | | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | @@ -45,10 +45,10 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 | | Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 | | Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 | -| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 | +| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | @@ -67,13 +67,13 @@ ### 在线 REQUEST 图 -在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。 当前在线强连通分量为: `Auth ↔ IAM`;`Firstleg ↔ Order` -`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 +`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 ### 后台 BACKGROUND 图 @@ -81,7 +81,7 @@ `Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java index 730b8908a..817d7eb81 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -84,6 +84,8 @@ public class CustomerDimensionInitializationRelay { String value = failure.getMessage(); if (value == null || value.isBlank()) value = failure.getClass().getSimpleName(); value = value.replaceAll("[\\r\\n\\t]+", " "); + value = value.replaceAll("(?i)Bearer\\s+[^\\s,;]+", "Bearer ***") + .replaceAll("(?i)(token|password)=([^\\s&,;]+)", "$1=***"); return value.length() > 1000 ? value.substring(0, 1000) : value; } diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index c7f0fe556..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,6 +25,9 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -47,4 +50,3 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" - diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java new file mode 100644 index 000000000..0c22f9ecf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomerDimensionInitializationSqlContractTest { + @Test + void latestIamSqlContainsStatusAndDurableTask() throws Exception { + String sql = new ClassPathResource("db/iam.sql").getContentAsString(StandardCharsets.UTF_8); + assertContract(sql); + } + + @Test + void idempotentStandaloneAndUnifiedSqlContainTheSameTaskContract() throws Exception { + String standalone = new ClassPathResource("db/customer-dimension-initialization-task.sql") + .getContentAsString(StandardCharsets.UTF_8); + Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + Path unifiedPath = Files.exists(direct) ? direct + : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); + String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8); + + assertContract(standalone); + assertContract(unified); + assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS"); + } + + @Test + void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception { + String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8); + assertSchedulerConfig(application); + for (String profile : new String[] {"local", "cn"}) { + Path direct = Path.of("config", "nacos", profile, "iam-service.yml"); + Path config = Files.exists(direct) ? direct + : Path.of("..", "config", "nacos", profile, "iam-service.yml"); + assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8)); + } + } + + private static void assertContract(String sql) { + assertThat(sql) + .contains("dimension_initialization_status") + .contains("iam_customer_dimension_initialization_task") + .contains("initialization_key") + .contains("attempt_count") + .contains("next_retry_at") + .contains("lease_until") + .contains("last_error_code") + .contains("task_version"); + } + + private static void assertSchedulerConfig(String yaml) { + assertThat(yaml) + .contains("customer-dimension-initialization:") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}") + .contains("${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java index 899ead0e7..bdd692767 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; 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.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -47,7 +48,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); verify(repository).markReady(task, workerId(), 91L); - verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), any(Boolean.class)); } @@ -60,7 +61,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); - verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), exhausted.capture()); assertFalse(exhausted.getValue()); } @@ -75,7 +76,7 @@ class CustomerDimensionInitializationRelayTest { relay.deliver(10001L); ArgumentCaptor exhausted = ArgumentCaptor.forClass(Boolean.class); - verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(), + verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(), anyString(), exhausted.capture()); assertTrue(exhausted.getValue()); verify(repository, never()).markReady(any(), anyString(), any()); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 51159c7d7..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -74,6 +74,8 @@ class MerchantOnboardingServiceTest { "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); assertEquals("测试企业", tenant.getValue().getLegalName()); assertEquals("张三", tenant.getValue().getLegalRepresentative()); @@ -122,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -199,4 +201,3 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } - diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index c9f7f82c6..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -313,6 +313,7 @@ class TenantServiceTest { 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); @@ -326,7 +327,7 @@ class TenantServiceTest { } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); @@ -491,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -529,4 +544,3 @@ class TenantServiceTest { return review; } } - diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 0acced4c7..f0627b41b 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -2,7 +2,7 @@ ## 1. 范围与生成方式 -本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。 可重复命令: @@ -16,15 +16,15 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice ``` -分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 +分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 68 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。 ## 2. 当前规模与 SCC | 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 | |---|---:|---:|---:|---:|---:|---:| -| main | 347 | 672 | 157 | 137 | 60 | 38 | -| test only | 98 | 0 | 67 | 0 | 0 | 0 | -| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 | +| main | 353 | 679 | 160 | 138 | 60 | 38 | +| test only | 103 | 0 | 68 | 0 | 0 | 0 | +| main + test | 456 | 1,206 | 164 | 257 | 108 | 76 | 主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: @@ -57,7 +57,8 @@ com.baiyue.lastleg.transfer.service | 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 | | 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC | | 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 | -| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 | +| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 | 另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 @@ -86,12 +87,13 @@ com.baiyue.lastleg.transfer.service | relabel-transfer-discard | inventory | 6 | | relabel-transfer-discard | shared-technical | 3 | | relabel-transfer-discard | tailleg-handoff | 14 | +| security-projection | shared-technical | 1 | | tailleg-handoff | dropship | 2 | | tailleg-handoff | inbound-pda | 6 | | tailleg-handoff | inventory | 2 | | tailleg-handoff | shared-technical | 2 | -当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。 +当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。 ## 5. Entity/DTO/Mapper 泄漏基线 @@ -175,8 +177,9 @@ Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermission ## 9. 当前只读合同 - `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。 -- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。 -- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。 +- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 26 条 main 切片边;历史边可减少,不可增加,并锁定安全投影 6 个类的位置、单一出向与零入向。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出、车辆切片边界及安全投影隔离。 +- `src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java`:用 ArchUnit 锁定投影内部只依赖本子域和根安全技术类、业务包不得反向依赖投影、DTO/Service 不依赖内部 Web Adapter。 最小验证: @@ -197,3 +200,11 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all - - 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 首切片完成后停止,不进入第二切片。 + +## 11. 海外安全投影合入后的边界 + +- 用户确认安全投影保留在 `lastleg-service` 部署单元内,不拆新微服务;逻辑上独立为 `security-projection` 切片。 +- Controller 仅映射 `/internal/lastleg/security/v1/events` 与 `/validate`,使用 `@Hidden` 并在进入投影或校验服务前调用根 `InternalServiceTokenGuard`。 +- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。 +- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。 +- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。 diff --git a/scripts/README.md b/scripts/README.md index 4b1eb9629..fac0cd8b6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,8 +31,8 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、海外安全服务部署映射、关键入口与排除目录 | -| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 仅用显式 `--require-spotbugs` 检查报告完整性且当前允许失败 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 | +| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 485ac8d42..fa082a7fd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,6 +130,19 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) +test('IAM customer dimension Feign is reachable only from the background relay', async () => { + const references = [] + for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (source.includes('CustomerDimensionRuleClient')) { + references.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(references.sort(), [ + 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' + ]) +}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ @@ -242,4 +255,3 @@ test('shared Feign timeout defaults and long-running export overrides do not dri const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8') assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5) }) - From 2dcfdd5fc8977303fb1e67c22fb2babfb73fdb84 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:54 +0800 Subject: [PATCH 187/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/iam-service.yml | 6 +-- iam/src/main/resources/application.yml | 6 +-- .../MerchantOnboardingServiceTest.java | 30 ++++++----- .../baiyue/iam/service/TenantServiceTest.java | 52 +++++++------------ .../synchronous-dependency-contract.test.mjs | 13 ----- 5 files changed, 43 insertions(+), 64 deletions(-) diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index f735164e7..52b571129 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,10 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} + diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index 5faf2522f..c01cc552b 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,9 +25,8 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} + security-event: + event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -50,3 +49,4 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" + diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 2b48686ef..c0d166639 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -42,6 +42,7 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; + @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -55,8 +56,8 @@ class MerchantOnboardingServiceTest { when(roles.selectOne(any())).thenReturn(ownerRole); var result = service.register(new MerchantRegistrationRequest( - 9001L, "注册人", "测试企业", "张三", "13800138000", - "owner@example.com", "CN", "深圳市宝安区")); + 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", + "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø")); ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); ArgumentCaptor user = ArgumentCaptor.forClass(IamUser.class); @@ -67,19 +68,21 @@ class MerchantOnboardingServiceTest { verify(memberships).insert(membership.capture()); verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); - verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); + verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "²âÊÔÆóҵ"); + verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); + verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); + verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", + "INITIAL_AUTHORIZATION"); verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); - assertEquals("PENDING", result.dimensionInitializationStatus()); - assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); - assertEquals("测试企业", tenant.getValue().getLegalName()); - assertEquals("张三", tenant.getValue().getLegalRepresentative()); - assertEquals("注册人", tenant.getValue().getContactName()); + assertEquals("²âÊÔÆóҵ", tenant.getValue().getLegalName()); + assertEquals("ÕÅÈý", tenant.getValue().getLegalRepresentative()); + assertEquals("ע²áÈË", tenant.getValue().getContactName()); assertEquals(9001L, user.getValue().getAuthUserId()); assertEquals("ACTIVE", user.getValue().getStatus()); assertEquals(result.tenantId(), membership.getValue().getTenantId()); @@ -124,7 +127,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -133,14 +136,14 @@ class MerchantOnboardingServiceTest { ownerRole.setStatus("ACTIVE"); when(roles.selectOne(any())).thenReturn(ownerRole); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) .when(dimensionRuleInitialization).initialize( org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, () -> service.register(new MerchantRegistrationRequest( - 9001L, "注册人", "测试企业", "张三", "13800138000", - "owner@example.com", "CN", "深圳市宝安区"))); + 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", + "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø"))); assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); } @@ -190,7 +193,7 @@ class MerchantOnboardingServiceTest { when(qualifications.selectCount(any())).thenReturn(0L); when(reviews.selectCount(any())).thenReturn(0L); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "²¹³¥ʧ°Ü")) .when(dimensionRuleInitialization).compensateRegistration(10001L); assertThrows(com.baiyue.common.core.exception.BizException.class, @@ -201,3 +204,4 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } + diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index eb7bb54dc..a7886a304 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -151,22 +151,22 @@ class TenantServiceTest { var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", - "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), - new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); + "רҵ°æ", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", "һÆÚÖصã¿ͻ§")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); assertEquals("Bob", updated.legalRepresentative()); assertEquals("alice_wechat", updated.contactWechat()); - assertEquals("专业版", updated.servicePackage()); + assertEquals("רҵ°æ", updated.servicePackage()); assertEquals("Sales Zhang", updated.salesOwnerName()); assertEquals(45, updated.paymentTermDays()); assertEquals("LIMITED_COMPANY", updated.enterpriseType()); assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); assertEquals("CNY", updated.registeredCapitalCurrency()); - assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); - assertEquals("一期重点客户", updated.internalRemark()); + assertEquals("ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", updated.registeredAddress()); + assertEquals("һÆÚÖصã¿ͻ§", updated.internalRemark()); assertEquals("DISABLED", disabled.status()); verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } @@ -178,7 +178,7 @@ class TenantServiceTest { BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, - "基础版", null, null, 30, null, null, null, "CNY", null, null))); + "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); assertEquals(ResultCode.CONFLICT, exception.getResultCode()); assertEquals("13800138000", tenant.getContactPhone()); @@ -273,7 +273,7 @@ class TenantServiceTest { BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, - "基础版", null, null, 30, null, null, null, "CNY", null, null))); + "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com"))); @@ -308,32 +308,32 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", - LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), + "alice@example.com", "»ù´¡°æ", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇø", "²ݸå¿ͻ§"), 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); - assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); - assertEquals("基础版", created.tenant().servicePackage()); + assertEquals("»ù´¡°æ", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); assertEquals("Password1234", created.temporaryPassword()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); + verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { - doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) + void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", null, null, 30, null, + "alice@example.com", "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null), 9001L)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); @@ -347,7 +347,7 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "基础版", null, null, 30, null, + "alice@example.com", "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null), 9001L); assertEquals(null, created.tenant().unifiedSocialCreditCode()); @@ -420,7 +420,7 @@ class TenantServiceTest { TenantAdministratorProvisioningService.ProvisioningResult.created( 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "资料完整"); + var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); assertEquals(10001L, result.tenantId()); assertEquals(true, result.administratorCreated()); @@ -431,6 +431,7 @@ class TenantServiceTest { verify(repository).saveReview(review); verify(repository).saveTenant(tenant); verify(securityEvents).tenantStatus(10001L, "ACTIVE"); + verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -446,7 +447,7 @@ class TenantServiceTest { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) .when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "资料完整"); + var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); assertEquals(false, result.administratorCreated()); assertEquals(null, result.administratorUsername()); @@ -470,7 +471,7 @@ class TenantServiceTest { doReturn(administrator).when(provisioning).initialize(any()); doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); - assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); + assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "×ÊÁÏÍêÕû")); verify(provisioning).compensate(any(), eq(administrator)); verify(repository, never()).saveTenant(tenant); @@ -492,26 +493,12 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } - @Test - void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { - IamTenant tenant = merchant(10001L, "ACTIVE"); - tenant.setDimensionInitializationStatus("FAILED"); - when(repository.findTenant(10001L)).thenReturn(tenant); - - var result = service.orderEligibility(10001L); - - assertEquals(false, result.eligible()); - assertEquals("Customer default dimensions are not ready", result.reason()); - verify(profileRepository, never()).findQualifications(10001L); - } - private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); - tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -544,3 +531,4 @@ class TenantServiceTest { return review; } } + diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa082a7fd..a9b69530f 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,19 +130,6 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) -test('IAM customer dimension Feign is reachable only from the background relay', async () => { - const references = [] - for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { - const source = await readFile(file, 'utf8') - if (source.includes('CustomerDimensionRuleClient')) { - references.push(path.relative(root, file).replaceAll('\\', '/')) - } - } - assert.deepEqual(references.sort(), [ - 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', - 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' - ]) -}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ From 505d448850428ec70ce5a9e5e4bfc534fa932828 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:41:02 +0800 Subject: [PATCH 188/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/iam-service.yml | 6 +-- iam/src/main/resources/application.yml | 6 +-- .../MerchantOnboardingServiceTest.java | 30 +++++------ .../baiyue/iam/service/TenantServiceTest.java | 52 ++++++++++++------- .../synchronous-dependency-contract.test.mjs | 13 +++++ 5 files changed, 64 insertions(+), 43 deletions(-) diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 52b571129..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -7,10 +7,10 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} - diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index c01cc552b..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -25,8 +25,9 @@ spring: baiyue: security-event: event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} + customer-dimension-initialization: + fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} + max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} notification: scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} @@ -49,4 +50,3 @@ logging: max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}" total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}" clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}" - diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index c0d166639..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -42,7 +42,6 @@ class MerchantOnboardingServiceTest { @MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization; @MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization; @MockBean private SecurityEventOutboxService securityEvents; - @MockBean private SecurityEventOutboxService securityEvents; @Resource private MerchantOnboardingService service; @Test void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() { @@ -56,8 +55,8 @@ class MerchantOnboardingServiceTest { when(roles.selectOne(any())).thenReturn(ownerRole); var result = service.register(new MerchantRegistrationRequest( - 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", - "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø")); + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区")); ArgumentCaptor tenant = ArgumentCaptor.forClass(IamTenant.class); ArgumentCaptor user = ArgumentCaptor.forClass(IamUser.class); @@ -68,21 +67,19 @@ class MerchantOnboardingServiceTest { verify(memberships).insert(membership.capture()); verify(membershipRoles).insert(roleGrant.capture()); verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L); - verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "²âÊÔÆóҵ"); - verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); - verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); - verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", - "INITIAL_AUTHORIZATION"); + verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业"); verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED"); verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE"); verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT", "INITIAL_AUTHORIZATION"); assertEquals("PROFILE_REQUIRED", result.tenantStatus()); + assertEquals("PENDING", result.dimensionInitializationStatus()); + assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus()); assertEquals(result.tenantId(), tenant.getValue().getId()); - assertEquals("²âÊÔÆóҵ", tenant.getValue().getLegalName()); - assertEquals("ÕÅÈý", tenant.getValue().getLegalRepresentative()); - assertEquals("ע²áÈË", tenant.getValue().getContactName()); + assertEquals("测试企业", tenant.getValue().getLegalName()); + assertEquals("张三", tenant.getValue().getLegalRepresentative()); + assertEquals("注册人", tenant.getValue().getContactName()); assertEquals(9001L, user.getValue().getAuthUserId()); assertEquals("ACTIVE", user.getValue().getStatus()); assertEquals(result.tenantId(), membership.getValue().getTenantId()); @@ -127,7 +124,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { + void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); @@ -136,14 +133,14 @@ class MerchantOnboardingServiceTest { ownerRole.setStatus("ACTIVE"); when(roles.selectOne(any())).thenReturn(ownerRole); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize( org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); var failure = assertThrows(com.baiyue.common.core.exception.BizException.class, () -> service.register(new MerchantRegistrationRequest( - 9001L, "ע²áÈË", "²âÊÔÆóҵ", "ÕÅÈý", "13800138000", - "owner@example.com", "CN", "ÉîÛÚÊб¦°²Çø"))); + 9001L, "注册人", "测试企业", "张三", "13800138000", + "owner@example.com", "CN", "深圳市宝安区"))); assertEquals(com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); } @@ -193,7 +190,7 @@ class MerchantOnboardingServiceTest { when(qualifications.selectCount(any())).thenReturn(0L); when(reviews.selectCount(any())).thenReturn(0L); org.mockito.Mockito.doThrow(new com.baiyue.common.core.exception.BizException( - com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "²¹³¥ʧ°Ü")) + com.baiyue.common.core.api.ResultCode.SERVICE_UNAVAILABLE, "补偿失败")) .when(dimensionRuleInitialization).compensateRegistration(10001L); assertThrows(com.baiyue.common.core.exception.BizException.class, @@ -204,4 +201,3 @@ class MerchantOnboardingServiceTest { verify(tenants, never()).deleteById((java.io.Serializable) any()); } } - diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index a7886a304..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -151,22 +151,22 @@ class TenantServiceTest { var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com", - "רҵ°æ", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), - new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", "һÆÚÖصã¿ͻ§")); + "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); assertEquals("Bob", updated.legalRepresentative()); assertEquals("alice_wechat", updated.contactWechat()); - assertEquals("רҵ°æ", updated.servicePackage()); + assertEquals("专业版", updated.servicePackage()); assertEquals("Sales Zhang", updated.salesOwnerName()); assertEquals(45, updated.paymentTermDays()); assertEquals("LIMITED_COMPANY", updated.enterpriseType()); assertEquals(LocalDate.of(2020, 1, 2), updated.establishedDate()); assertEquals(new BigDecimal("5000000.00"), updated.registeredCapital()); assertEquals("CNY", updated.registeredCapitalCurrency()); - assertEquals("ÉîÛÚÊÐÄÏɽÇøʾÀý· 1 ºÅ", updated.registeredAddress()); - assertEquals("һÆÚÖصã¿ͻ§", updated.internalRemark()); + assertEquals("深圳市南山区示例路 1 号", updated.registeredAddress()); + assertEquals("一期重点客户", updated.internalRemark()); assertEquals("DISABLED", disabled.status()); verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } @@ -178,7 +178,7 @@ class TenantServiceTest { BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null, - "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); + "基础版", null, null, 30, null, null, null, "CNY", null, null))); assertEquals(ResultCode.CONFLICT, exception.getResultCode()); assertEquals("13800138000", tenant.getContactPhone()); @@ -273,7 +273,7 @@ class TenantServiceTest { BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null, - "»ù´¡°æ", null, null, 30, null, null, null, "CNY", null, null))); + "基础版", null, null, 30, null, null, null, "CNY", null, null))); BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L, new UpdateProfileRequest("New name", "New legal", "Bob", "Alice", "13800138000", "alice@example.com"))); @@ -308,32 +308,32 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", - LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "ÉîÛÚÊÐÄÏɽÇø", "²ݸå¿ͻ§"), + "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), 9001L); assertEquals("PROFILE_REQUIRED", created.tenant().status()); + assertEquals("PENDING", created.tenant().dimensionInitializationStatus()); assertEquals("Bob", created.tenant().legalRepresentative()); - assertEquals("»ù´¡°æ", created.tenant().servicePackage()); + assertEquals("基础版", created.tenant().servicePackage()); assertEquals("13800138000", created.administratorUsername()); assertEquals("Password1234", created.temporaryPassword()); verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy()) && Long.valueOf(9001L).equals(tenant.getUpdatedBy()))); verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L); verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); - verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED"); verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L)); verify(repository, never()).insertReview(any()); } @Test - void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { - doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "³õʼ»¯ʧ°Ü")) + void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() { + doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败")) .when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class)); BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", null, null, 30, null, + "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, failure.getResultCode()); @@ -347,7 +347,7 @@ class TenantServiceTest { var created = service.register(new RegisterRequest( "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", - "alice@example.com", "»ù´¡°æ", null, null, 30, null, + "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L); assertEquals(null, created.tenant().unifiedSocialCreditCode()); @@ -420,7 +420,7 @@ class TenantServiceTest { TenantAdministratorProvisioningService.ProvisioningResult.created( 81L, "merchant_10001", "Password1234")).when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); + var result = service.review(71L, 9001L, true, "资料完整"); assertEquals(10001L, result.tenantId()); assertEquals(true, result.administratorCreated()); @@ -431,7 +431,6 @@ class TenantServiceTest { verify(repository).saveReview(review); verify(repository).saveTenant(tenant); verify(securityEvents).tenantStatus(10001L, "ACTIVE"); - verify(securityEvents).tenantStatus(10001L, "ACTIVE"); } @Test @@ -447,7 +446,7 @@ class TenantServiceTest { doReturn(TenantAdministratorProvisioningService.ProvisioningResult.existing()) .when(provisioning).initialize(any()); - var result = service.review(71L, 9001L, true, "×ÊÁÏÍêÕû"); + var result = service.review(71L, 9001L, true, "资料完整"); assertEquals(false, result.administratorCreated()); assertEquals(null, result.administratorUsername()); @@ -471,7 +470,7 @@ class TenantServiceTest { doReturn(administrator).when(provisioning).initialize(any()); doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review); - assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "×ÊÁÏÍêÕû")); + assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整")); verify(provisioning).compensate(any(), eq(administrator)); verify(repository, never()).saveTenant(tenant); @@ -493,12 +492,26 @@ class TenantServiceTest { assertEquals(true, withQualification.eligible()); } + @Test + void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + tenant.setDimensionInitializationStatus("FAILED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + + var result = service.orderEligibility(10001L); + + assertEquals(false, result.eligible()); + assertEquals("Customer default dimensions are not ready", result.reason()); + verify(profileRepository, never()).findQualifications(10001L); + } + private IamTenant merchant(long id, String status) { IamTenant tenant = new IamTenant(); tenant.setId(id); tenant.setTenantCode("TEN" + id); tenant.setTenantType("MERCHANT"); tenant.setStatus(status); + tenant.setDimensionInitializationStatus("READY"); tenant.setTenantName("Example Merchant"); tenant.setLegalName("Example Merchant Ltd"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); @@ -531,4 +544,3 @@ class TenantServiceTest { return review; } } - diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index a9b69530f..fa082a7fd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -130,6 +130,19 @@ test('every reviewed synchronous client has a complete and exclusive executable } }) +test('IAM customer dimension Feign is reachable only from the background relay', async () => { + const references = [] + for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) { + const source = await readFile(file, 'utf8') + if (source.includes('CustomerDimensionRuleClient')) { + references.push(path.relative(root, file).replaceAll('\\', '/')) + } + } + assert.deepEqual(references.sort(), [ + 'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java' + ]) +}) test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { const clients = new Map([ From 3ab1a9d1734f2b5f359b4bc4fca1854aaf9517fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:01:52 +0800 Subject: [PATCH 189/231] fix(lastleg): remove duplicate vehicle repository mapper --- .../exchange/ExchangeMapperConfiguration.java | 10 ---------- .../masterdata/MasterDataMapperConfigurationTest.java | 8 ++++++++ 2 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java deleted file mode 100644 index 7a1d22ad4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/ExchangeMapperConfiguration.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.masterdata.exchange; - -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.context.annotation.Configuration; - -/** 注册尾程主数据域的所有 Mapper。 */ -@Configuration -@MapperScan("com.baiyue.lastleg.masterdata") -public class ExchangeMapperConfiguration { -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java index 9bf3ebe8d..a38935e41 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/MasterDataMapperConfigurationTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.mybatis.spring.annotation.MapperScan; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class MasterDataMapperConfigurationTest { @Test @@ -14,4 +15,11 @@ class MasterDataMapperConfigurationTest { assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.masterdata"); assertThat(mapperScan.annotationClass()).isEqualTo(Mapper.class); } + + @Test + void broadExchangeMapperScanMustNotBeReintroduced() { + assertThatThrownBy(() -> Class.forName( + "com.baiyue.lastleg.masterdata.exchange.ExchangeMapperConfiguration")) + .isInstanceOf(ClassNotFoundException.class); + } } From 4e8c15dbfa2bd57038c795e6d4ba8dcd50e8f36e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:17:32 +0800 Subject: [PATCH 190/231] test: wait for customer dimension initialization --- ...nt-qualification-fixture-contract.test.mjs | 3 +++ .../merchant-qualification-fixture.mjs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 8d080ea93..6560cab8f 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -15,6 +15,9 @@ test('默认全量验收先准备已审核商户再运行订单链路', () => { test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () => { assert.match(fixture, /files\/qualifications\/\$\{encodeURIComponent\(tenantId\)\}\/required-package/) assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/submit-review/) + assert.match(fixture, /iam\/tenants\/\$\{encodeURIComponent\(tenantId\)\}\/dimension-initialization/) + assert.match(fixture, /status === 'READY'/) + assert.match(fixture, /setTimeout\(resolve, 500\)/) assert.match(fixture, /iam\/reviews\/\$\{encodeURIComponent\(review\.id\)\}\/approve/) assert.match(fixture, /item\.status === 'APPROVED'/) assert.match(fixture, /MERCHANT_TEST_USERNAME/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 6e586c0e5..61c541603 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -9,6 +9,23 @@ function id(value) { return value === undefined || value === null ? '' : String(value) } +async function waitForDimensionInitialization(context, tenantId) { + let response = null + let status = 'PENDING' + let attempt = 0 + for (; attempt < 30; attempt += 1) { + response = await context.request('GET', + `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/dimension-initialization`) + status = response.http === 200 && String(response.body?.code) === '200' + ? response.body?.data?.status : 'REQUEST_FAILED' + if (status === 'READY' || status === 'FAILED') break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + context.assert(MODULE, '客户默认尺寸后台初始化已就绪', response, status === 'READY', + `tenantId=${tenantId}, status=${status}, attempts=${attempt + 1}`, { deploymentBlocker: true }) + if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`) +} + export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const phone = await context.findAvailablePhone(MODULE) @@ -63,6 +80,8 @@ export async function run(context) { `tenantId=${tenantId}, reviewId=${id(review?.id) || 'missing'}`, { deploymentBlocker: true }) if (!review?.id) throw new Error('未找到隔离下单客户的待审核单') + await waitForDimensionInitialization(context, tenantId) + const approveResponse = await context.request('POST', `/api/platform/v1/iam/reviews/${encodeURIComponent(review.id)}/approve`, { comment: `${context.marker} 全量验收正式资质审核通过` }) From 25cb3bfa54e114eaad6212985db46ac134ada028 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:25:38 +0800 Subject: [PATCH 191/231] test: await PDA projection and pending receipts --- .../phase4-overseas-pda-contract.test.mjs | 5 +++++ .../phase4-overseas-pda.mjs | 20 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index ee95227c6..2f54f9709 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -35,9 +35,14 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /第二仓库操作员抢占同柜返回409/) assert.match(source, /WAREHOUSE_PICKER/) assert.match(source, /lastleg:pda:operate/) + assert.match(source, /安全投影已收敛/) + assert.match(source, /projectionReady = success\(projectionResponse\) \|\| notFound\(projectionResponse\)/) + assert.match(source, /setTimeout\(resolve, 500\)/) assert.match(source, /超收被拒绝/) assert.match(source, /重复箱新键被拒绝/) assert.match(source, /key\(context, `fba-bulk-\$\{index\}`\)/) + assert.match(source, /packageCount = Number\(resolved\.data\?\.pendingPackages \|\| 0\)/) + assert.match(source, /if \(packageCount <= 0\) continue/) assert.match(source, /key\(context, `overseas-carton-\$\{index\}`\)/) assert.match(source, /模式硬拒绝/) assert.match(source, /current返回404正常空态/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index b1f177e91..e04e67ca4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -111,7 +111,21 @@ async function createPicker(context, index, overseasWarehouseId, access) { const token = success(login) ? login.body?.data?.overseasAccessToken : null context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) - return { membershipId, username, token: nonBlank(token) ? token : null } + if (!nonBlank(token)) return { membershipId, username, token: null } + + let projectionResponse = null + let projectionReady = false + let projectionAttempt = 0 + for (; projectionAttempt < 30; projectionAttempt += 1) { + projectionResponse = await context.requestOverseas('GET', + '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + projectionReady = success(projectionResponse) || notFound(projectionResponse) + if (projectionReady || !forbidden(projectionResponse)) break + await new Promise((resolve) => setTimeout(resolve, 500)) + } + context.addStep(MODULE, `海外PDA操作员${index}安全投影已收敛`, projectionResponse, projectionReady, + `attempts=${projectionAttempt + 1}; ${trace(projectionResponse)}`, OPTIONS) + return { membershipId, username, token: projectionReady ? token : null } } async function disablePicker(context, picker, index) { @@ -294,13 +308,15 @@ async function receiveFba(context, token, inbound, pallet, producer) { } let accepted = 0 for (const [index, sku] of skus.entries()) { - const { skuCode, packageCount } = sku + const { skuCode } = sku const resolvedResponse = await context.requestOverseas('POST', '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) const resolvable = success(resolvedResponse) || notFound(resolvedResponse) context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const packageCount = Number(resolved.data?.pendingPackages || 0) + if (packageCount <= 0) continue const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, packageCount, idempotencyKey: key(context, `fba-bulk-${index}`) } const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) From 62407610cbafe0ca029c7663f0ef62f8b6596bf4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 17:54:50 +0800 Subject: [PATCH 192/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/PdaPalletOperationMapper.java | 2 +- .../mapper/PdaStocktakeItemMapper.java | 2 +- ...PalletOperationControllerContractTest.java | 10 ++++ .../PdaInventoryOperationContractTest.java | 9 ++++ .../phase4-overseas-pda-contract.test.mjs | 8 ++- .../phase4-overseas-pda.mjs | 51 +++++++++++++++---- 6 files changed, 68 insertions(+), 14 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index 91be448d6..f28f4c05a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -24,7 +24,7 @@ public interface PdaPalletOperationMapper extends BaseMapper @Param("idempotencyKey") String idempotencyKey); @Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " + - "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + + "AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " + "ORDER BY b.customer_id,b.inbound_code,p.id") List content(@Param("warehouseId") String warehouseId, @Param("palletId") long palletId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java index e74e61729..4347c6a68 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java @@ -55,7 +55,7 @@ public interface PdaStocktakeItemMapper extends BaseMapper { int counted(@Param("taskId") long taskId); @Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " + - "AND actual_packages IS NOT NULL AND difference_packages<>0") + "AND actual_packages IS NOT NULL AND difference_packages<>0") int differences(@Param("taskId") long taskId); @Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java index 1f728cc1d..fc8416556 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.controller; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; @@ -24,4 +26,12 @@ class PdaPalletOperationControllerContractTest { .flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet()); assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths); } + + @Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception { + String sql = String.join("", PdaPalletOperationMapper.class.getMethod("content", String.class, long.class) + .getAnnotation(Select.class).value()); + assertTrue(sql.contains("receiving_packages)>0")); + assertFalse(sql.contains("<")); + assertFalse(sql.contains(">")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java index eaef73a37..b67a31d65 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java @@ -4,6 +4,8 @@ import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController; import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest; import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController; import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests; +import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper; +import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; @@ -38,4 +40,11 @@ class PdaInventoryOperationContractTest { assertThat(fields).doesNotContainAnyElementsOf(forbidden); } } + + @Test + void stocktakeAnnotationSqlUsesNativeComparisonOperators() throws Exception { + String sql = String.join("", PdaStocktakeItemMapper.class.getMethod("differences", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("difference_packages<>0").doesNotContain("<", ">"); + } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index 2f54f9709..d59239abe 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -54,7 +54,13 @@ test('PDA module covers the container-number receiving contract and excludes out assert.match(source, /目的仓 .* 不得代替物理仓/) assert.match(source, /retained as acceptance evidence/) assert.match(source, /preferredProducer\?\.cartonMarks\?\.length/) - assert.match(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) + assert.match(source, /const cartonCandidates =/) + assert.match(source, /candidateExcluded = notFound\(response\)/) + assert.match(source, /if \(candidateExcluded\) continue/) + assert.match(source, /const fixturesConserved = cartonFixtures\.length === expectedPending/) + assert.match(source, /OVERSEAS实装箱唛与待收箱数守恒/) + assert.match(source, /for \(const \[index, fixture\] of cartonFixtures\.entries\(\)\)/) + assert.doesNotMatch(source, /for \(const \[index, cartonMark\] of cartonMarks\.entries\(\)\)/) assert.match(source, /last-leg\/orders\?page=1&size=200&containerNo=/) assert.match(source, /phase4OverseasPda\.orderId = id\(order\.id\)/) assert.match(source, /phase4OverseasPda\.customerId = id\(order\.customerId\)/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index e04e67ca4..2fd9bf5c8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -338,23 +338,52 @@ async function receiveFba(context, token, inbound, pallet, producer) { } async function receiveOverseas(context, token, inbounds, pallets, producerCartonMarks = []) { - const cartonMarks = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) + const cartonCandidates = [...new Set((Array.isArray(producerCartonMarks) ? producerCartonMarks : [producerCartonMarks]) .map(id).filter(nonBlank))] - if (!cartonMarks.length) { + if (!cartonCandidates.length) { blockerGap(context, 'OVERSEAS逐箱链', '缺少本轮PDA任务冻结箱唛,逐箱实收不能使用伪造箱号') return false } - for (const [index, cartonMark] of cartonMarks.entries()) { - const resolved = await pda(context, token, 'POST', `解析OVERSEAS冻结箱唛${index + 1}`, - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) - const inbound = inbounds.find((item) => item.inboundCode === resolved.data?.inboundCode) - const pallet = inbound ? pallets.get(inbound.inboundCode) : null - if (!success(resolved.response) || !inbound || !pallet) { - blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, - `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved.data?.inboundCode || 'missing'}`, - 'fixture-mismatch', resolved.response) + const expectedPending = inbounds.reduce((total, inbound) => total + Number(inbound.pendingPackages || 0), 0) + const cartonFixtures = [] + let lastResponse = null + for (const [index, cartonMark] of cartonCandidates.entries()) { + const response = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) + lastResponse = response + const candidateExcluded = notFound(response) + context.addStep(MODULE, `筛选OVERSEAS实装箱唛候选${index + 1}`, response, + success(response) || candidateExcluded, + `${candidateExcluded ? 'candidateSelected=false' : 'candidateSelected=true'}; ${trace(response)}`, OPTIONS) + if (candidateExcluded) continue + if (!success(response)) { + blockerGap(context, `OVERSEAS箱唛候选${cartonMark}预检`, + '候选箱唛既非本柜冻结事实,也不是未被最终实装选中的A0404;禁止继续收货', + 'fixture-mismatch', response) return false } + const resolved = response.body?.data + const inbound = inbounds.find((item) => item.inboundCode === resolved?.inboundCode) + const pallet = inbound ? pallets.get(inbound.inboundCode) : null + if (!inbound || !pallet) { + blockerGap(context, `OVERSEAS箱唛${cartonMark}归属`, + `冻结箱唛未解析到带真实托盘的本柜入仓码;inboundCode=${resolved?.inboundCode || 'missing'}`, + 'fixture-mismatch', response) + return false + } + cartonFixtures.push({ cartonMark, pallet }) + } + const fixturesConserved = cartonFixtures.length === expectedPending + context.assert(MODULE, 'OVERSEAS实装箱唛与待收箱数守恒', lastResponse, fixturesConserved, + `resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}; ${trace(lastResponse)}`, OPTIONS) + if (!fixturesConserved) { + blockerGap(context, 'OVERSEAS逐箱冻结事实', + `候选池未完整覆盖本柜真实冻结箱唛;resolvedFixtures=${cartonFixtures.length}, pendingPackages=${expectedPending}`, + 'fixture-mismatch', lastResponse) + return false + } + for (const [index, fixture] of cartonFixtures.entries()) { + const { cartonMark, pallet } = fixture const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, `overseas-carton-${index}`) } const first = await pda(context, token, 'POST', `OVERSEAS逐箱实收${index + 1}`, From 335ad8269f062d2650a0c8fa3415105607a0be31 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:07:48 +0800 Subject: [PATCH 193/231] fix: execute overseas pallet transitions --- .../WarehousePalletTransitionMapper.java | 4 +-- ...usePalletTransitionMapperContractTest.java | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java index 751a848c0..9f4b423ba 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapper.java @@ -20,12 +20,12 @@ public interface WarehousePalletTransitionMapper extends BaseMapper'INACTIVE' AND deleted=0") long countActiveLocation(@Param("warehouseId") long warehouseId, @Param("locationId") long locationId); @Update("UPDATE wh_pallet SET status=#{targetStatus},location_id=#{targetLocationId},version=version+1," + "updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{palletId} AND customer_id=0 " + - "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") + "AND warehouse_id=#{warehouseId} AND version=#{expectedVersion} AND status<>'SCRAPPED' AND deleted=0") int transition(@Param("warehouseId") long warehouseId, @Param("palletId") long palletId, @Param("targetStatus") String targetStatus, @Param("targetLocationId") long targetLocationId, @Param("expectedVersion") int expectedVersion); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java new file mode 100644 index 000000000..06f4c4d57 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/mapper/WarehousePalletTransitionMapperContractTest.java @@ -0,0 +1,32 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WarehousePalletTransitionMapperContractTest { + + @Test + void annotationSqlUsesNativeComparisonOperatorsInsteadOfXmlEntities() throws Exception { + Method locationQuery = WarehousePalletTransitionMapper.class.getMethod( + "countActiveLocation", long.class, long.class); + String selectSql = String.join(" ", locationQuery.getAnnotation(Select.class).value()); + + Method transition = Arrays.stream(WarehousePalletTransitionMapper.class.getMethods()) + .filter(method -> method.getName().equals("transition")) + .findFirst() + .orElseThrow(); + String updateSql = String.join(" ", transition.getAnnotation(Update.class).value()); + + assertTrue(selectSql.contains("status<>'INACTIVE'")); + assertTrue(updateSql.contains("status<>'SCRAPPED'")); + assertFalse(selectSql.contains("<") || selectSql.contains(">")); + assertFalse(updateSql.contains("<") || updateSql.contains(">")); + } +} From ca69434698371134b27fcf880ecacbe7991e5d70 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:11:29 +0800 Subject: [PATCH 194/231] fix: persist PDA movement state semantics --- .../service/PdaPalletOperationService.java | 3 ++- .../move/service/PdaInventoryMoveService.java | 11 ++++---- .../PdaPalletOperationServiceTest.java | 25 +++++++++++++++++++ .../service/PdaInventoryMoveServiceTest.java | 14 ++++++++--- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 3bc77d10c..076d3c66d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -264,7 +264,8 @@ public class PdaPalletOperationService { ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId()); ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode()); ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey); - ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet); + ledger.setStatusFrom("OCCUPIED"); + ledger.setStatusTo("UNBOUND".equals(targetPallet) ? "UNBOUND" : "OCCUPIED"); ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after); ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason)); ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java index 03be9225d..ab7b71d58 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java @@ -117,8 +117,7 @@ public class PdaInventoryMoveService { position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode()); position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt); positions.updateById(position); - ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(), - target.locationCode(), completedAt); + ledger(operation, position, balance, operatorKey, key, request.reason(), completedAt); } operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED"); operation.setCompletedAt(completedAt); @@ -150,18 +149,18 @@ public class PdaInventoryMoveService { } private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance, - String operator, String key, String reason, String source, String target, - LocalDateTime now) { + String operator, String key, String reason, LocalDateTime now) { InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId()); row.setBalanceId(balance.getId()); row.setPositionId(position.getId()); row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId())); row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId()); row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo()); - row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE"); + row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode()); + row.setBusinessType("INTERNAL_MOVE"); row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo()); row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo()); row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId()); - row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position); + row.setStatusFrom("OCCUPIED"); row.setStatusTo("OCCUPIED"); long packages = physical(position); row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages); row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now); ledgers.insert(row); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 59301bc76..d13c5b887 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -3,6 +3,9 @@ package com.baiyue.lastleg.palletops.service; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; @@ -15,6 +18,7 @@ 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.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.TransactionDefinition; @@ -94,6 +98,27 @@ class PdaPalletOperationServiceTest { verifyNoInteractions(ledgers, history); } + @Test void inventoryLedgerStoresStateSemanticsInsteadOfLongPalletCodes() { + InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100"); + balance.setOverseasWarehouseId("88"); balance.setInboundOrderId(300L); balance.setInboundCode("IN-1"); + InventoryPosition position = new InventoryPosition(); position.setId(100L); position.setPalletId(20L); + position.setPalletNo("PDAOPS-ADMIN-API-20260825095854036-SWAP-TARGET"); + position.setBatchNo("DEFAULT"); position.setWarehouseLocationCode("PDAOPS-LONG-LOCATION-CODE"); + PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(400L); + operation.setOperationType("PALLET_SWAP"); + + ReflectionTestUtils.invokeMethod(service, "ledger", identity(), operation, balance, position, + "idem:out", 5L, -5L, 0L, + "PDAOPS-ADMIN-API-20260825095854036-SOURCE", + "PDAOPS-ADMIN-API-20260825095854036-TARGET", "swap"); + + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertEquals("OCCUPIED", ledger.getValue().getStatusFrom()); + assertEquals("OCCUPIED", ledger.getValue().getStatusTo()); + assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode()); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java index cdb3714ae..7207e9c2f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java @@ -60,9 +60,10 @@ class PdaInventoryMoveServiceTest { @Test void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() { + String targetLocationCode = "PDAOPS-ADMIN-API-20260825095854036-MOVE"; when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2)); when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01")); - when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01")); + when(warehouse.resolveLocation(900L, targetLocationCode)).thenReturn(location(21L, targetLocationCode)); InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100"); position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L); position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01"); @@ -76,7 +77,7 @@ class PdaInventoryMoveServiceTest { eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false)); var response = service.move(900L, "operator-1", - new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")); + new PdaInventoryMoveRequest("PAL-1", targetLocationCode, 2, "move-1", "库位优化")); verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L && value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"), @@ -84,11 +85,16 @@ class PdaInventoryMoveServiceTest { ArgumentCaptor updated = ArgumentCaptor.forClass(InventoryPosition.class); verify(positions).updateById(updated.capture()); assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L); - assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01"); + assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode); assertThat(response.packageCount()).isEqualTo(6L); assertThat(response.palletVersion()).isEqualTo(3); assertThat(response.status()).isEqualTo("COMPLETED"); - verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class)); + ArgumentCaptor ledger = ArgumentCaptor.forClass(InventoryLedger.class); + verify(ledgers).insert(ledger.capture()); + assertThat(ledger.getValue().getStatusFrom()).isEqualTo("OCCUPIED"); + assertThat(ledger.getValue().getStatusTo()).isEqualTo("OCCUPIED"); + assertThat(ledger.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode); + verify(moves).updateById(any(PdaInventoryMove.class)); } @Test From b6226f1ca6a3a3da644780500bbe153720d3bb4c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:21:39 +0800 Subject: [PATCH 195/231] fix: replay completed pallet operations --- .../service/PdaPalletOperationService.java | 21 +++++++++++++++++++ .../PdaPalletOperationServiceTest.java | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 076d3c66d..35bcdee74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -71,6 +71,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity, PalletOperationRequests.Assemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), request.target(), request.sources()); PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode())); @@ -85,6 +88,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity, PalletOperationRequests.Disassemble request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_DISASSEMBLE", + request.idempotencyKey(), request); + if (replay != null) return replay; LinkedHashMap expectations = new LinkedHashMap<>(); expectations.put(code(request.source().palletCode()), request.source()); request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target())); @@ -109,6 +115,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity, PalletOperationRequests.Swap request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), request.source(), List.of(request.target())); PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); @@ -129,6 +138,9 @@ public class PdaPalletOperationService { public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity, PalletOperationRequests.Unbind request) { + PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_UNBIND", + request.idempotencyKey(), request); + if (replay != null) return replay; Map pallets = resolvePallets(identity.overseasWarehouseId(), List.of(request.source())); PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode())); @@ -413,6 +425,15 @@ public class PdaPalletOperationService { return value; } + private PalletOperationResponses.Operation replayIfPresent(PdaWarehouseIdentity identity, String type, + String key, Object request) { + String normalizedKey = required(key, "幂等键不能为空"); + PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), normalizedKey); + if (existing == null) return null; + validateExisting(existing, type, hash(request)); + return read(existing).asReplay(); + } + private void saveTransitions(long operationId, List transitions) { newTransaction.executeWithoutResult(status -> { PdaPalletOperation operation = mapper.selectById(operationId); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index d13c5b887..ed82fcf29 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -27,6 +27,7 @@ import org.springframework.transaction.support.DefaultTransactionStatus; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.time.LocalDateTime; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -119,6 +120,25 @@ class PdaPalletOperationServiceTest { assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode()); } + @Test void exactReplayReturnsStoredResultBeforeValidatingChangedPalletVersions() throws Exception { + var request = new PalletOperationRequests.Swap("idem-replay", + new PalletOperationRequests.PalletExpectation("SOURCE", 1), + new PalletOperationRequests.PalletExpectation("TARGET", 2), "swap"); + PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(500L); + existing.setOperationType("PALLET_SWAP"); existing.setStatus("COMPLETED"); + existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request)); + var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation( + 500L, "PALLET_SWAP", "idem-replay", "COMPLETED", false, LocalDateTime.now(), List.of()); + existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored)); + when(mapper.findOperation("7", "idem-replay")).thenReturn(existing); + + var replay = service.swap(identity(), request); + + assertTrue(replay.replayed()); + assertEquals(500L, replay.operationId()); + verifyNoInteractions(physical); + } + private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); } private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) { return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id, From 65c01f8b3d30a7e64142b9761067322fe06bbe34 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 18:32:12 +0800 Subject: [PATCH 196/231] test: isolate phase4 carrier fixtures --- scripts/admin-test-env-acceptance.mjs | 4 ++++ .../phase3-phase4-producer-fixture-contract.test.mjs | 1 + 2 files changed, 5 insertions(+) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 6bb27b5ea..1b6f37546 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1029,6 +1029,10 @@ export class AcceptanceRun { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) let restoreFixture = null + // Each producer variant must create and advance its own loading, outbound and carrier facts. + // Keeping the outer run's objects here can make a later container write PICKUP_READY nodes + // to an earlier carrier task while still reporting the variant as ready. + for (const key of createdKeys) delete this.createdResources[key] this.marker = variantMarker this.context.orderDestinationType = destinationType this.context.orderFulfillmentType = fulfillmentType diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs index 32699b741..df0841e3e 100644 --- a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -112,6 +112,7 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before } for (const key of ['phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady']) assert.match(entry, new RegExp(`'${key}'`)) + assert.match(entry, /for \(const key of createdKeys\) delete this\.createdResources\[key\]/) assert.match(pickupChain, /shippingReady/) assert.match(entry, /carrierScheduleQueryId/) assert.match(pickupChain, /pickup\?\.marker === variantMarker/) From 7d36759f3f7eb4c215410b412bec295c170394ed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 19:07:34 +0800 Subject: [PATCH 197/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common-web/AGENTS.md | 2 + .../common/web/OutboundTraceHeaders.java | 40 ++++++++++++ .../common/web/RestClientTraceCustomizer.java | 24 +++++++ .../web/RestClientTraceInterceptor.java | 21 ++++++ ...ientTracePropagationAutoConfiguration.java | 25 +++++++ .../com/baiyue/common/web/TraceParent.java | 19 ++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../common/web/OutboundTraceHeadersTest.java | 41 ++++++++++++ .../web/RestClientTraceInterceptorTest.java | 60 +++++++++++++++++ ...TracePropagationAutoConfigurationTest.java | 37 +++++++++++ .../baiyue/common/web/TraceParentTest.java | 8 +++ docs/Order-Firstleg在线依赖解环设计.md | 60 +++++++++++++++++ docs/同步调用依赖与失败传播治理基线.md | 10 ++- event-bridge-service/AGENTS.md | 2 + .../docs/event-trace-propagation.md | 16 +++++ .../service/HttpFirstlegEventClient.java | 7 +- .../service/HttpLastlegEventClient.java | 7 +- .../HttpSecurityProjectionEventClient.java | 8 ++- .../service/OverseasEventDispatcher.java | 16 ++++- .../EventBridgeHttpTracePropagation.java | 19 ++++++ .../trace/EventBridgeTraceScope.java | 35 ++++++++++ .../service/OverseasEventDispatcherTest.java | 10 ++- .../EventBridgeTracePropagationTest.java | 65 +++++++++++++++++++ scripts/java-quality-baseline.json | 59 +++++++++++++++++ scripts/java-quality-summary.mjs | 2 +- scripts/java-quality-summary.test.mjs | 10 +++ scripts/synchronous-dependency-baseline.mjs | 3 +- .../synchronous-dependency-contract.test.mjs | 22 ++++++- 28 files changed, 613 insertions(+), 16 deletions(-) create mode 100644 common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java create mode 100644 common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java create mode 100644 common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java create mode 100644 docs/Order-Firstleg在线依赖解环设计.md create mode 100644 event-bridge-service/docs/event-trace-propagation.md create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java create mode 100644 scripts/java-quality-baseline.json diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index d2312b7cf..019c951df 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,7 @@ | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | +| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -27,5 +28,6 @@ - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 +- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java new file mode 100644 index 000000000..bbd1be9a4 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java @@ -0,0 +1,40 @@ +package com.baiyue.common.web; + +import org.slf4j.MDC; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** Immutable standard headers for one synchronous outbound child span. */ +public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */ + public static OutboundTraceHeaders fromCurrentOrNew() { + String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID)); + String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID)) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(requestId, traceId); + } + + /** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */ + public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) { + String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(UUID.randomUUID().toString(), traceId); + } + + public String spanId() { + return TraceParent.parse(traceParent).orElseThrow().parentId(); + } + + private static OutboundTraceHeaders child(String requestId, String traceId) { + TraceParent child = TraceParent.outboundChild(traceId); + return new OutboundTraceHeaders(requestId, child.traceId(), child.value()); + } + + private static String safeRequestId(String value) { + return value != null && SAFE_REQUEST_ID.matcher(value).matches() + ? value : UUID.randomUUID().toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java new file mode 100644 index 000000000..0fcf7ad9f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java @@ -0,0 +1,24 @@ +package com.baiyue.common.web; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +/** Installs the trace interceptor after ordinary RestClient customizers. */ +public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered { + private final RestClientTraceInterceptor interceptor; + + public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void customize(RestClient.Builder builder) { + builder.requestInterceptor(interceptor); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java new file mode 100644 index 000000000..488cd808f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java @@ -0,0 +1,21 @@ +package com.baiyue.common.web; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** Applies the trusted synchronous trace context to Spring RestClient requests. */ +public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()); + request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + return execution.execute(request, body); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..6acccad5d --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** Adds trace propagation to Spring-managed RestClient builders. */ +@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration") +@ConditionalOnClass({RestClient.class, RestClientCustomizer.class}) +public class RestClientTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(RestClientTraceInterceptor.class) + RestClientTraceInterceptor restClientTraceInterceptor() { + return new RestClientTraceInterceptor(); + } + + @Bean + RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) { + return new RestClientTraceCustomizer(interceptor); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2ece6f081..88ae5829f 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -3,6 +3,7 @@ package com.baiyue.common.web; import java.security.SecureRandom; import java.util.HexFormat; import java.util.Optional; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,6 +64,24 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); } + /** + * Normalizes a trace ID from a trusted persisted/internal context. + * + *

Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because + * existing event envelopes historically persisted UUID trace IDs.

+ */ + public static Optional normalizeTrustedTraceId(String value) { + if (validLegacyTraceId(value)) { + return Optional.of(value.toLowerCase(Locale.ROOT)); + } + if (value != null && value.matches( + "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) { + String compact = value.replace("-", "").toLowerCase(Locale.ROOT); + return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty(); + } + return Optional.empty(); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 06a277bae..2d417843c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration +com.baiyue.common.web.RestClientTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java new file mode 100644 index 000000000..c0f3c006a --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundTraceHeadersTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void currentContextCreatesAValidChildWithoutUsingBusinessValues() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + + OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew(); + + assertThat(headers.requestId()).isEqualTo("req-trusted"); + assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(headers.traceParent()) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() { + OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "550e8400-e29b-41d4-a716-446655440000"); + OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "ORDER:customer-99:booking-123"); + + assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(invalid.traceId()).matches("[0-9a-f]{32}") + .isNotEqualTo("ORDER:customer-99:booking-123"); + assertThat(invalid.requestId()).matches("[0-9a-f-]{36}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java new file mode 100644 index 000000000..b8c6e74e7 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java @@ -0,0 +1,60 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientTraceInterceptorTest { + private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception { + MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id"); + request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + AtomicReference captured = new AtomicReference<>(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> { + captured.set(outbound.getHeaders()); + return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK); + }); + + assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-rest-client"); + assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception { + MDC.put(CorrelationContext.TRACE_ID, "business-order-123"); + MockClientHttpRequest request = new MockClientHttpRequest(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> + new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK)); + + assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}").isNotEqualTo("business-order-123"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..f2edb83d2 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RestClientTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class)); + + @Test + void registersInterceptorAndIndependentCustomizer() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RestClientTraceInterceptor.class); + assertThat(context).hasBean("baiyueTraceRestClientCustomizer"); + assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1); + RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class); + assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + RestClient.Builder builder = mock(RestClient.Builder.class); + customizer.customize(builder); + verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class)); + }); + } + + @Test + void reusesApplicationProvidedTraceInterceptor() { + contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index e59be28aa..e67870a1c 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -51,4 +51,12 @@ class TraceParentTest { assertThat(child.value()).matches( "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); } + + @Test + void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() { + assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000")) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(TraceParent.parse( + "00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty(); + } } diff --git a/docs/Order-Firstleg在线依赖解环设计.md b/docs/Order-Firstleg在线依赖解环设计.md new file mode 100644 index 000000000..d8828c29e --- /dev/null +++ b/docs/Order-Firstleg在线依赖解环设计.md @@ -0,0 +1,60 @@ +# Order↔Firstleg 在线依赖解环设计 + +## 结论 + +当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含 +`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。 + +建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有 +`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和 +拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留 +`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。 + +## 当前可执行调用 + +| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 | +|---|---|---|---|---| +| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 | +| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 | +| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 | + +## 事实所有权 + +- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。 +- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。 +- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。 +- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。 + +## 推荐退出方案 + +### 1. 先退出货物在线读取 + +复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以 +`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。 + +不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。 + +### 2. 再将履约拆分改为可靠后台命令 + +Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。 + +Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。 + +这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。 + +## 必须由产品冻结的决策 + +1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。 +2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。 + +上述任一决策未冻结前,不实施业务解环。 + +## 最小实施与验证边界(决策冻结后) + +- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。 +- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。 +- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。 +- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。 +- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index b9c4dde5f..f7cba31aa 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -26,7 +26,7 @@ | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | | Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | -| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` | | Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | | Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | | Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | @@ -79,9 +79,9 @@ 后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: -`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 @@ -108,6 +108,10 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 +### Order↔Firstleg 第二阶段结论 + +具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。 + ## 退出顺序 1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 4b88bd092..99d0553fb 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -18,6 +18,7 @@ | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | | 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | +| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -29,6 +30,7 @@ - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 - 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 +- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 ## 修改与验证 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md new file mode 100644 index 000000000..00c2e42c3 --- /dev/null +++ b/event-bridge-service/docs/event-trace-propagation.md @@ -0,0 +1,16 @@ +# Event Bridge Trace 传播基线 + +- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 +- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 +- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 +- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 +- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 +- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` + 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 + +## 共享契约缺口 + +当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 +无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 +`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 +Payload、信封字段、哈希规则或可靠性状态机。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index fe26409f2..f888cd04f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpFirstlegEventClient implements FirstlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -37,9 +39,10 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode responseBody; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java index e3b3dd5b8..2e01d3981 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -4,6 +4,7 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +20,7 @@ import java.net.http.HttpResponse; public class HttpLastlegEventClient implements LastlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -38,10 +40,11 @@ public class HttpLastlegEventClient implements LastlegEventClient { row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); String body = objectMapper.writeValueAsString(envelope); - HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) .timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java index 6c4b7666e..9e55d56bd 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -4,9 +4,11 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -19,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { private final ObjectMapper objectMapper; private final EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; private HttpClient httpClient; @@ -39,9 +42,10 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); - HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode body; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 54cd77867..b20be547f 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -7,10 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.eventbridge.trace.EventBridgeTraceScope; +import com.baiyue.common.web.CorrelationContext; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.slf4j.MDC; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; @@ -42,6 +45,12 @@ public class OverseasEventDispatcher { } BridgeInbox event = inboxMapper.selectById(eventId); + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + dispatchClaimed(eventId, event); + } + } + + private void dispatchClaimed(String eventId, BridgeInbox event) { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); @@ -57,8 +66,8 @@ public class OverseasEventDispatcher { : "FAILED_LEASE_LOST"; finishAttempt(attempt, attemptStatus, null, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); - log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", - eventId, retryAttempt, deliveryAttemptNo, error); + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); return; } @@ -66,7 +75,8 @@ public class OverseasEventDispatcher { String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); - log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", + eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); return; } finishAttempt(attempt, "DELIVERED", responseCode, null); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java new file mode 100644 index 000000000..dc2c78bd3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import org.springframework.stereotype.Component; + +import java.net.http.HttpRequest; + +/** Applies a fresh child span to one raw JDK HttpClient request. */ +@Component +public class EventBridgeHttpTracePropagation { + public HttpRequest.Builder apply(HttpRequest.Builder builder) { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + return builder + .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) + .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) + .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java new file mode 100644 index 000000000..82139ab51 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java @@ -0,0 +1,35 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.slf4j.MDC; + +import java.util.Map; + +/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ +public final class EventBridgeTraceScope implements AutoCloseable { + private final Map previous; + + private EventBridgeTraceScope(Map previous) { + this.previous = previous; + } + + public static EventBridgeTraceScope open(BridgeInbox event) { + Map previous = MDC.getCopyOfContextMap(); + OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); + MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); + MDC.put(CorrelationContext.TRACE_ID, local.traceId()); + MDC.put(CorrelationContext.SPAN_ID, local.spanId()); + return new EventBridgeTraceScope(previous); + } + + @Override + public void close() { + if (previous == null) { + MDC.clear(); + } else { + MDC.setContextMap(previous); + } + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index aa9f11d14..5224a11e5 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -7,11 +7,13 @@ import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import com.baiyue.common.web.CorrelationContext; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.MDC; import java.net.URI; import java.time.Duration; @@ -63,7 +65,12 @@ class OverseasEventDispatcherTest { @Test void successfulDeliveryIsConfirmedAndAudited() throws Exception { - when(eventClient.deliver(event)).thenReturn("200"); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + when(eventClient.deliver(event)).thenAnswer(invocation -> { + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + return "200"; + }); when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); dispatcher.dispatchOne("event-1"); @@ -74,6 +81,7 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(transportCount("DELIVERED")).isEqualTo(1.0); assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); } @Test diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java new file mode 100644 index 000000000..1dbf17567 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java @@ -0,0 +1,65 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.net.URI; +import java.net.http.HttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeTracePropagationTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { + BridgeInbox event = new BridgeInbox(); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + event.setCorrelationId("ORDER:customer-99:booking-123"); + event.setEventId("event-business-id"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + String localSpan = MDC.get(CorrelationContext.SPAN_ID); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) + .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) + .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) + .header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); + + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) + .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) + .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") + .doesNotContain(localSpan); + } + + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { + MDC.put("existing", "preserved"); + BridgeInbox event = new BridgeInbox(); + event.setTraceId("JWT-or-business-id-value"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") + .isNotEqualTo(event.getTraceId()); + } + + assertThat(MDC.get("existing")).isEqualTo("preserved"); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } +} diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json new file mode 100644 index 000000000..1ed545842 --- /dev/null +++ b/scripts/java-quality-baseline.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "mode": "report-only", + "tools": { + "jacocoMavenPlugin": "0.8.12", + "spotbugsMavenPlugin": "4.9.8.5" + }, + "topology": { + "modules": 22, + "codeModules": 21, + "services": 15 + }, + "openapi": { + "documents": 26, + "coveredServices": 7, + "codeFirstServices": 8 + }, + "reports": { + "jacocoPresent": 21, + "spotbugsPresent": 21, + "spotbugsFindings": 780, + "spotbugsByPriority": { + "1": 1, + "2": 779 + }, + "spotbugsByCategory": { + "BAD_PRACTICE": 10, + "CORRECTNESS": 3, + "EXPERIMENTAL": 4, + "MALICIOUS_CODE": 699, + "MT_CORRECTNESS": 2, + "PERFORMANCE": 18, + "STYLE": 44 + } + }, + "modules": [ + { "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 }, + { "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 }, + { "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 }, + { "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 }, + { "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 }, + { "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 }, + { "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 }, + { "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 }, + { "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 }, + { "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 }, + { "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 }, + { "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 }, + { "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 }, + { "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 }, + { "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 }, + { "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 }, + { "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 }, + { "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 }, + { "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 }, + { "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 }, + { "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 } + ] +} diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs index f54740dca..23c84d407 100644 --- a/scripts/java-quality-summary.mjs +++ b/scripts/java-quality-summary.mjs @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const normalize = (value) => value.replaceAll('\\', '/') -const attribute = (source, name) => source.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] +const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined) async function exists(file) { try { diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8744660a1..989dcfe43 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -21,6 +21,11 @@ test('parses JaCoCo line coverage and SpotBugs findings without quality threshol byPriority: { 1: 1, 2: 1 }, byCategory: { CORRECTNESS: 1, STYLE: 1 } }) + assert.deepEqual(parseSpotbugsReport(""), { + findings: 1, + byPriority: { 2: 1 }, + byCategory: { MALICIOUS_CODE: 1 } + }) assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) }) @@ -58,10 +63,15 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) + const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) assert.equal(summary.openapi.documents, 26) assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) + assert.equal(baseline.mode, 'report-only') + assert.deepEqual(baseline.topology, summary.topology) + assert.equal(baseline.modules.length, 21) + assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 6c69f4df7..de60f65a6 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -64,7 +64,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', - 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + 'firstleg-service', 'order-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa082a7fd..bb8fcb0e7 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -54,6 +54,7 @@ const expectedBackgroundEdges = [ 'firstleg-service->event-bridge-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', + 'firstleg-service->order-service', 'iam-service->event-bridge-service', 'iam-service->firstleg-service', 'lastleg-service->event-bridge-service', @@ -190,7 +191,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ['firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', @@ -204,6 +205,25 @@ test('overseas security projection stays in Lastleg infrastructure and backgroun assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) +test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => { + const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST')) + const orderToFirstleg = requestClients.filter(client => + client.source === 'order-service' && client.target === 'firstleg-service') + const firstlegToOrder = requestClients.filter(client => + client.source === 'firstleg-service' && client.target === 'order-service') + assert.deepEqual(orderToFirstleg.map(client => client.path).sort(), [ + 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java' + ]) + assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java' + ]) + assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component => + component.includes('firstleg-service') && component.includes('order-service'))) +}) + test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { const summary = Object.fromEntries(USER_ENTRIES.map(entry => { const paths = longestSimplePaths(entry.service) From 6e55d94f3336d4225428181cc06b7f93b961e8a8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 19:44:10 +0800 Subject: [PATCH 198/231] fix: add ocean booking link candidates query --- .../PlatformOceanBookingController.java | 5 ++ .../oceanbooking/dto/OceanBookingDtos.java | 19 +++++++ .../mapper/OceanBookingMapper.java | 30 +++++++++++ .../repository/OceanBookingRepository.java | 5 ++ .../service/OceanBookingService.java | 6 +++ .../resources/openapi/ocean-bookings-v1.yaml | 51 +++++++++++++++++++ ...ormOceanBookingControllerContractTest.java | 18 ++++++- .../OceanBookingMapperContractTest.java | 20 ++++++++ .../service/OceanBookingServiceTest.java | 24 +++++++++ 9 files changed, 177 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index a6ed9d70d..486fb960e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -27,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> linkCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query)); + } @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 044d6b76f..b74f650d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -12,6 +12,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; import java.time.LocalDateTime; import java.math.BigDecimal; @@ -26,6 +27,24 @@ public final class OceanBookingDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 32) String containerType, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public LinkCandidateQuery { + if (page == null) page = 1L; + if (size == null) size = 20L; + } + } + + public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, + int requiredQuantity, long allocatedQuantity, long remainingQuantity, + String customerName, String warehouseName, String channelName) {} + public record PartyRequest(@NotBlank @Size(max = 24) String partyType, @NotBlank @Size(max = 255) String partyName, @Size(max = 1000) String address, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index d806df7a9..519d10053 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -41,6 +43,34 @@ public interface OceanBookingMapper extends BaseMapper { """) Page selectPage(Page page, @Param("query") Query query); + @Select(""" + SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, + requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, + requirement.quantity required_quantity, + COALESCE(allocated.allocated_quantity,0) allocated_quantity, + requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity, + booking.customer_name,booking.warehouse_name,booking.channel_name + FROM fl_ocean_booking booking + JOIN fl_ocean_booking_container_requirement requirement + ON requirement.ocean_booking_id=booking.id + LEFT JOIN ( + SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation + GROUP BY ocean_booking_id,requirement_id + ) allocated ON allocated.ocean_booking_id=booking.id + AND allocated.requirement_id=requirement.id + WHERE booking.status='RELEASED' + AND booking.warehouse_id=#{query.warehouseId} + AND booking.channel_code=#{query.channelCode} + AND requirement.container_type=#{query.containerType} + AND requirement.capacity_cbm=#{query.capacityCbm} + AND requirement.max_weight_kg=#{query.maxWeightKg} + AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) + ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC + """) + Page selectLinkCandidates(Page page, + @Param("query") LinkCandidateQuery query); + @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, updated_by=#{operatorId},updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1f5f9b27c..747c91ebc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.mapper.*; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -30,6 +32,9 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 7b4ad9e48..2d68dfedb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -55,6 +55,12 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), + query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(), + query.maxWeightKg(), query.page(), query.size())); + } + public List channels(long originWarehouseId) { if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); Long versionId = priceBooks.activeVersionId(); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 363e22b73..5900284ee 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -28,6 +28,25 @@ paths: parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} + /api/platform/v1/ocean-bookings/link-candidates: + get: + summary: Page released booking requirement candidates for one container + description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} + - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': + description: Business envelope containing a bounded candidate page + content: + application/json: + schema: {$ref: '#/components/schemas/LinkCandidateResponse'} /api/platform/v1/ocean-bookings/channels: get: summary: List ACTIVE channels for an origin warehouse @@ -94,6 +113,38 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + LinkCandidate: + type: object + required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] + properties: + oceanBookingId: {type: string, pattern: '^[0-9]+$'} + bookingNo: {type: string} + requirementId: {type: string, pattern: '^[0-9]+$'} + containerType: {type: string} + capacityCbm: {type: number} + maxWeightKg: {type: number} + requiredQuantity: {type: integer, minimum: 1} + allocatedQuantity: {type: integer, format: int64, minimum: 0} + remainingQuantity: {type: integer, format: int64, minimum: 1} + customerName: {type: string} + warehouseName: {type: string} + channelName: {type: string} + LinkCandidatePage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + LinkCandidateResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/LinkCandidatePage'} Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index df43c1be5..6bdfc820d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), @@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -57,6 +59,20 @@ class PlatformOceanBookingControllerContractTest { .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); } + @Test + void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception { + Method method = method("linkCandidates"); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/link-candidates"); + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/link-candidates:") + .contains("x-permission: platform:firstleg:ocean-booking:list") + .contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg") + .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("booking detail calls are not used"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 050195e76..835f826b8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; @@ -36,4 +37,23 @@ class OceanBookingMapperContractTest { .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + + @Test + void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("booking.status='RELEASED'") + .contains("booking.warehouse_id=#{query.warehouseId}") + .contains("booking.channel_code=#{query.channelCode}") + .contains("requirement.container_type=#{query.containerType}") + .contains("requirement.capacity_cbm=#{query.capacityCbm}") + .contains("requirement.max_weight_kg=#{query.maxWeightKg}") + .contains("FROM fl_ocean_booking_container_allocation") + .contains("GROUP BY ocean_booking_id,requirement_id") + .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") + .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") + .doesNotContain("fl_container_booking", "SELECT booking.*"); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index f9b3ce64a..2bea449df 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; @@ -170,6 +171,29 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", + new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + "客户A", "深圳中转仓", "美西海运"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, + " SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null)); + + assertThat(result.records()).containsExactly(candidate); + assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); + ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); + verify(repository).linkCandidates(query.capture()); + assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().page()).isEqualTo(1L); + assertThat(query.getValue().size()).isEqualTo(20L); + verify(repository, never()).find(anyLong()); + verify(repository, never()).requirements(anyLong()); + verify(repository, never()).containers(anyLong()); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); From ead9565a377a90d315d05f2365660676f5925e04 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:13:48 +0800 Subject: [PATCH 199/231] fix: align ocean booking link eligibility --- .../mapper/ContainerBookingMapper.java | 2 - .../oceanbooking/dto/OceanBookingDtos.java | 3 -- ...OceanBookingContainerAllocationMapper.java | 13 +++++ .../mapper/OceanBookingMapper.java | 18 +++---- .../repository/OceanBookingRepository.java | 3 ++ .../service/OceanBookingService.java | 3 +- .../repository/ContainerRepository.java | 1 - .../firstleg/service/ContainerService.java | 11 ++--- .../resources/openapi/ocean-bookings-v1.yaml | 19 ++++++-- ...ormOceanBookingControllerContractTest.java | 12 ++++- .../OceanBookingMapperContractTest.java | 30 ++++++++++-- .../service/OceanBookingServiceTest.java | 18 ++++++- .../ContainerBookingMapperContractTest.java | 9 ---- .../service/ContainerServiceTest.java | 48 +++++++++++++++++-- 14 files changed, 142 insertions(+), 48 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index f5e905ab8..62599bc9f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -171,8 +171,6 @@ public interface ContainerBookingMapper extends BaseMapper#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId); - @Update(""" UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index b74f650d3..7481453cb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -12,7 +12,6 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; -import jakarta.validation.constraints.DecimalMin; import java.time.LocalDateTime; import java.math.BigDecimal; @@ -30,8 +29,6 @@ public final class OceanBookingDtos { public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 32) String containerType, - @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, - @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, @Min(1) Long page, @Min(1) @Max(200) Long size) { public LinkCandidateQuery { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index cca20bf30..77b31e191 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { @Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}") int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId); + + @Select(""" + SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + WHERE allocation.ocean_booking_id=#{oceanBookingId} + AND allocation.requirement_id=#{requirementId} + """) + long countActiveLinked(@Param("oceanBookingId") long oceanBookingId, + @Param("requirementId") long requirementId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 519d10053..598aad9ef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -26,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper { WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name, (SELECT party.party_name FROM fl_ocean_booking_party party WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name, - (SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation - JOIN fl_container_booking container ON container.id=allocation.container_booking_id - WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count + COALESCE((SELECT SUM(requirement.quantity) + FROM fl_ocean_booking_container_requirement requirement + WHERE requirement.ocean_booking_id=booking.id),0) container_count FROM fl_ocean_booking booking WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId}) AND booking.status IN ('DRAFT','RELEASED') @@ -54,17 +54,19 @@ public interface OceanBookingMapper extends BaseMapper { JOIN fl_ocean_booking_container_requirement requirement ON requirement.ocean_booking_id=booking.id LEFT JOIN ( - SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity - FROM fl_ocean_booking_container_allocation - GROUP BY ocean_booking_id,requirement_id + SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation allocation + JOIN fl_container_booking container ON container.id=allocation.container_booking_id + AND container.record_status='ACTIVE' + AND container.ocean_booking_id=allocation.ocean_booking_id + AND container.ocean_booking_requirement_id=allocation.requirement_id + GROUP BY allocation.ocean_booking_id,allocation.requirement_id ) allocated ON allocated.ocean_booking_id=booking.id AND allocated.requirement_id=requirement.id WHERE booking.status='RELEASED' AND booking.warehouse_id=#{query.warehouseId} AND booking.channel_code=#{query.channelCode} AND requirement.container_type=#{query.containerType} - AND requirement.capacity_cbm=#{query.capacityCbm} - AND requirement.max_weight_kg=#{query.maxWeightKg} AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 747c91ebc..ca7f77216 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -49,6 +49,9 @@ public class OceanBookingRepository { public List parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public long activeLinkedCount(long oceanBookingId, long requirementId) { + return allocations.countActiveLinked(oceanBookingId, requirementId); + } public List containers(long id) { Map requirementById = requirements(id).stream() .collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 2d68dfedb..a8b265082 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -57,8 +57,7 @@ public class OceanBookingService { public PageResult linkCandidates(LinkCandidateQuery query) { return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), - query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(), - query.maxWeightKg(), query.page(), query.size())); + query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); } public List channels(long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index 60fbe9d8c..9a425a5e9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -46,7 +46,6 @@ public class ContainerRepository { public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; } public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; } public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; } - public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; } public int updateTransport(long id, String containerNo, String voyageNo, int version, long operatorId, String operatorName) { return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 7b52c4c8e..8a9d5571e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -85,17 +85,14 @@ public class ContainerService { if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱"); OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId()); if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在"); - long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count(); + long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId()); if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限"); if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在"); if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) || !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) - || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType()) - || container.getCapacityCbm() == null || requirement.getCapacityCbm() == null - || container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null - || container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0 - || container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致"); - if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致"); + || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { + throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); + } if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking, request.expectedVersion(), operatorId, operatorName) != 1) { throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试"); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 5900284ee..8b7097c85 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -2,7 +2,12 @@ openapi: 3.0.3 info: {title: Ocean Booking API, version: 2.0.0} paths: /api/platform/v1/ocean-bookings: - get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}} + get: + summary: List DRAFT and RELEASED bookings + description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers. + responses: + '200': + description: Booking page; each row uses the frozen requirement-total containerCount semantic post: summary: Create an editable DRAFT description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created. @@ -31,14 +36,12 @@ paths: /api/platform/v1/ocean-bookings/link-candidates: get: summary: Page released booking requirement candidates for one container - description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used. + description: Requires platform:firstleg:ocean-booking:list. Eligibility matches only warehouseId, channelCode and containerType, with remainingQuantity greater than zero. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. x-permission: platform:firstleg:ocean-booking:list parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} - - {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} - - {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} responses: @@ -145,6 +148,14 @@ components: code: {type: string, example: '200'} message: {type: string, nullable: true} data: {$ref: '#/components/schemas/LinkCandidatePage'} + OceanBookingListRow: + type: object + properties: + containerCount: + type: integer + format: int64 + minimum: 0 + description: Sum of all container requirement quantities; independent of allocation, linking or unlinking. Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 6bdfc820d..a7200cd27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -68,11 +68,21 @@ class PlatformOceanBookingControllerContractTest { assertThat(yaml) .contains("/api/platform/v1/ocean-bookings/link-candidates:") .contains("x-permission: platform:firstleg:ocean-booking:list") - .contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg") + .contains("warehouseId", "channelCode", "containerType") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("Capacity and max weight are derived display snapshots and never eligibility fields") .contains("booking detail calls are not used"); } + @Test + void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("containerCount is the sum of quantity across all container requirements") + .contains("never represents linked or ACTIVE containers") + .contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 835f826b8..c4dcec3dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @@ -21,7 +22,10 @@ class OceanBookingMapperContractTest { .contains("booking.status=#{query.status}") .contains("booking.booking_no LIKE") .contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'") - .contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count") + .contains("COALESCE((SELECT SUM(requirement.quantity)") + .contains("FROM fl_ocean_booking_container_requirement requirement") + .contains("requirement.ocean_booking_id=booking.id),0) container_count") + .doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking") .contains("ORDER BY booking.updated_at DESC,booking.id DESC"); } @@ -48,12 +52,28 @@ class OceanBookingMapperContractTest { .contains("booking.warehouse_id=#{query.warehouseId}") .contains("booking.channel_code=#{query.channelCode}") .contains("requirement.container_type=#{query.containerType}") - .contains("requirement.capacity_cbm=#{query.capacityCbm}") - .contains("requirement.max_weight_kg=#{query.maxWeightKg}") .contains("FROM fl_ocean_booking_container_allocation") - .contains("GROUP BY ocean_booking_id,requirement_id") + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") - .doesNotContain("fl_container_booking", "SELECT booking.*"); + .doesNotContain("fl_container_booking", "SELECT booking.*", + "requirement.capacity_cbm=#{query.capacityCbm}", + "requirement.max_weight_kg=#{query.maxWeightKg}"); + } + + @Test + void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception { + String sql = String.join("\n", OceanBookingContainerAllocationMapper.class + .getDeclaredMethod("countActiveLinked", long.class, long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("container.record_status='ACTIVE'") + .contains("container.ocean_booking_id=allocation.ocean_booking_id") + .contains("container.ocean_booking_requirement_id=allocation.requirement_id") + .contains("allocation.ocean_booking_id=#{oceanBookingId}") + .contains("allocation.requirement_id=#{requirementId}"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 2bea449df..3af6f71b7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -171,6 +171,22 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() { + OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L); + OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L); + multipleTypes.setContainerCount(4L); + OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L); + emptyDraft.setContainerCount(0L); + when(repository.page(any())).thenReturn(new PageResult<>( + List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1)); + + PageResult result = service.page(new Query(null, null, null, 1, 20)); + + assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount) + .containsExactly(1L, 4L, 0L); + } + @Test void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", @@ -179,7 +195,7 @@ class OceanBookingServiceTest { when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, - " SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null)); + " SEA-US ", " 40HC ", null, null)); assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index 68a9ec0f3..16944ca9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest { .contains("customer_item.account_id = #{query.customerId}"); } - @Test - @DisplayName("首次关联客户隔离查询使用原生不等号") - void customerMismatchQueryUsesNativeSqlOperator() throws Exception { - String sql = ContainerBookingMapper.class - .getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class) - .getAnnotation(Select.class).value()[0]; - assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>"); - } - @Test @DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致") void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 8c6f0a5b3..4dba3b293 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -288,7 +288,7 @@ class ContainerServiceTest { void linksReleasedBookingAfterPreloading() { ContainerBookingEntity container = booking("EDITING", 2); container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC"); - container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000")); + container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); @@ -296,7 +296,7 @@ class ContainerServiceTest { when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of()); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"), same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1); @@ -311,6 +311,24 @@ class ContainerServiceTest { verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class)); } + @Test + @DisplayName("首次关联拒绝装柜仓库不一致") + void rejectsLinkWhenWarehouseDiffers() { + assertThreeFieldMismatch(102L, "SEA-US", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝物流渠道不一致") + void rejectsLinkWhenChannelDiffers() { + assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); + } + + @Test + @DisplayName("首次关联拒绝柜型不一致") + void rejectsLinkWhenContainerTypeDiffers() { + assertThreeFieldMismatch(101L, "SEA-US", "45HC"); + } + @Test @DisplayName("未发布订舱不可关联") void rejectsDraftBooking() { @@ -333,12 +351,10 @@ class ContainerServiceTest { booking.setId(21L); booking.setStatus("RELEASED"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setId(31L); requirement.setQuantity(1); - OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); - allocation.setRequirementId(31L); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); - when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation)); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L); assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) @@ -347,6 +363,28 @@ class ContainerServiceTest { any(), anyInt(), anyLong(), anyString()); } + private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel, + String requirementType) { + ContainerBookingEntity container = booking("WAIT_LOADING", 2); + container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setContainerType("40HC"); + OceanBookingEntity oceanBooking = new OceanBookingEntity(); + oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); + oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); + OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType); + when(repository.find(11L)).thenReturn(container); + when(oceanBookings.find(21L)).thenReturn(oceanBooking); + when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); + when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L); + + assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", + new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2))) + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); + verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), + any(), anyInt(), anyLong(), anyString()); + } + @Test @DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜") void unlinksEmptySimulationDirectly() { From 5d2b6c22e6348e6b04829f85bafcb0b90a28b0ec Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:17:12 +0800 Subject: [PATCH 200/231] test: freeze server-filtered booking candidates --- .../main/resources/openapi/ocean-bookings-v1.yaml | 2 +- ...latformOceanBookingControllerContractTest.java | 5 ++++- .../service/OceanBookingServiceTest.java | 15 +++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8b7097c85..a7ac3f556 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -36,7 +36,7 @@ paths: /api/platform/v1/ocean-bookings/link-candidates: get: summary: Page released booking requirement candidates for one container - description: Requires platform:firstleg:ocean-booking:list. Eligibility matches only warehouseId, channelCode and containerType, with remainingQuantity greater than zero. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. + description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command. x-permission: platform:firstleg:ocean-booking:list parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index a7200cd27..7a4ba2a08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -71,7 +71,10 @@ class PlatformOceanBookingControllerContractTest { .contains("warehouseId", "channelCode", "containerType") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") .contains("Capacity and max weight are derived display snapshots and never eligibility fields") - .contains("booking detail calls are not used"); + .contains("Returns only linkable requirement candidates") + .contains("An empty result is a normal successful page") + .contains("booking detail calls are not used") + .doesNotContain("mismatchReasons", "disabledReason"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 3af6f71b7..7cbc45b12 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -210,6 +210,21 @@ class OceanBookingServiceTest { verify(repository, never()).containers(anyLong()); } + @Test + void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + PageResult result = service.linkCandidates( + new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null)); + + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(result.pages()).isZero(); + verify(repository, times(1)).linkCandidates(any()); + assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3); From 44fee01761b57483106002e103b1f0a0d76f53d6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 20:21:42 +0800 Subject: [PATCH 201/231] test: align active booking allocation contract --- .../oceanbooking/repository/OceanBookingMapperContractTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index c4dcec3dd..d4557b21e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -59,7 +59,7 @@ class OceanBookingMapperContractTest { .contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id") .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") - .doesNotContain("fl_container_booking", "SELECT booking.*", + .doesNotContain("SELECT booking.*", "requirement.capacity_cbm=#{query.capacityCbm}", "requirement.max_weight_kg=#{query.maxWeightKg}"); } From 15421db4215f57d5eb908e3ab68265d570f4f96b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:06:24 +0800 Subject: [PATCH 202/231] fix(event-bridge): restore overseas event delivery --- deploy/sql/海外区域统一增量-MySQL8.sql | 107 ++++++++++++++++++ .../service/EventInboxService.java | 10 +- .../service/EventInboxServiceTest.java | 16 +++ scripts/overseas-deployment-contract.test.mjs | 18 +++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 882d2a042..7504a0f81 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,6 +1,113 @@ -- 海外区域固定增量入口。空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线; +-- 本入口同时初始化海外 Event Bridge 独立库,避免国内/海外实例误共用 Inbox。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE baiyue_event_bridge_overseas; + +CREATE TABLE IF NOT EXISTS bridge_outbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', + retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', + lease_owner VARCHAR(128) NULL COMMENT '租约持有者', + lease_until DATETIME(3) NULL COMMENT '租约截止时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + delivered_at DATETIME(3) NULL COMMENT '投递成功时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), + KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件待投递箱'; + +CREATE TABLE IF NOT EXISTS bridge_inbox ( + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT NOT NULL COMMENT '事件契约版本', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', + aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + aggregate_version BIGINT NOT NULL COMMENT '聚合版本', + handover_version BIGINT NULL COMMENT '跨区交接版本', + trace_id VARCHAR(128) NOT NULL COMMENT '链路标识', + correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识', + causation_id VARCHAR(128) NULL COMMENT '上游事件标识', + occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间', + payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要', + payload_json JSON NOT NULL COMMENT '事件负载', + status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', + lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者', + lease_until DATETIME(3) NULL COMMENT '消费租约截止时间', + retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次消费时间', + last_error VARCHAR(1000) NULL COMMENT '最后错误', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + processed_at DATETIME(3) NULL COMMENT '消费完成时间', + PRIMARY KEY (event_id), + UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), + KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) +) ENGINE=InnoDB COMMENT='海外跨区域事件已接收箱'; + +CREATE TABLE IF NOT EXISTS bridge_delivery_attempt ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识', + source_region VARCHAR(32) NOT NULL COMMENT '来源区域', + target_region VARCHAR(32) NOT NULL COMMENT '目标区域', + attempt_no INT NOT NULL COMMENT '投递序号', + status VARCHAR(32) NOT NULL COMMENT '投递结果', + response_code VARCHAR(64) NULL COMMENT '对端响应码', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + started_at DATETIME(3) NOT NULL COMMENT '开始时间', + finished_at DATETIME(3) NULL COMMENT '完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), + KEY idx_bridge_attempt_event (event_id,started_at) +) ENGINE=InnoDB COMMENT='海外跨区域事件投递尝试记录'; + +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_event_bridge_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_event_bridge_overseas' + AND table_name IN ('bridge_outbox','bridge_inbox','bridge_delivery_attempt')) <> 3 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 三张表未完整创建'; + END IF; + IF (SELECT COUNT(DISTINCT CONCAT(table_name, ':', index_name)) FROM information_schema.statistics + WHERE table_schema='baiyue_event_bridge_overseas' + AND ((table_name='bridge_outbox' AND index_name IN ('PRIMARY','uk_bridge_outbox_idempotency','idx_bridge_outbox_schedule')) + OR (table_name='bridge_inbox' AND index_name IN ('PRIMARY','uk_bridge_inbox_idempotency','idx_bridge_inbox_consume')) + OR (table_name='bridge_delivery_attempt' AND index_name IN ('PRIMARY','uk_bridge_attempt','idx_bridge_attempt_event')))) <> 9 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='海外区域初始化失败:Event Bridge 索引未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_event_bridge_schema(); +DROP PROCEDURE IF EXISTS assert_overseas_event_bridge_schema; + CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index f4adf8f3d..f904c7984 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -14,7 +14,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.Objects; /** @@ -78,7 +80,7 @@ public class EventInboxService { && Objects.equals(existing.getAggregateId(), envelope.aggregateId()) && Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion()) && Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion()) - && Objects.equals(existing.getOccurredAt(), envelope.occurredAt()) + && Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt())) && Objects.equals(existing.getTraceId(), envelope.traceId()) && Objects.equals(existing.getCorrelationId(), envelope.correlationId()) && Objects.equals(existing.getCausationId(), envelope.causationId()); @@ -100,7 +102,7 @@ public class EventInboxService { inbox.setTraceId(envelope.traceId()); inbox.setCorrelationId(envelope.correlationId()); inbox.setCausationId(envelope.causationId()); - inbox.setOccurredAt(envelope.occurredAt()); + inbox.setOccurredAt(normalizeOccurredAt(envelope.occurredAt())); inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT)); try { inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload())); @@ -111,4 +113,8 @@ public class EventInboxService { inbox.setReceivedAt(LocalDateTime.now()); return inbox; } + + private static Instant normalizeOccurredAt(Instant value) { + return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS); + } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index c8ee93232..6789ed520 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -65,6 +65,22 @@ class EventInboxServiceTest { assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } + @Test + void 数据库毫秒精度截断后相同事件仍判定为重复() { + EventEnvelope source = envelope("sha256:placeholder"); + EventEnvelope precise = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + Instant.parse("2026-07-29T08:30:00.123456789Z"), source.sourceRegion(), source.targetRegion(), + source.tenantId(), source.aggregateType(), source.aggregateId(), source.aggregateVersion(), + source.handoverVersion(), source.traceId(), source.correlationId(), source.causationId(), + source.payload(), source.payloadHash()); + EventEnvelope envelope = withHash(precise); + BridgeInbox existing = existing(envelope, envelope.payloadHash()); + existing.setOccurredAt(Instant.parse("2026-07-29T08:30:00.123Z")); + when(store.findByEventId(envelope.eventId())).thenReturn(existing); + + assertThat(service.receive(envelope).result()).isEqualTo("重复"); + } + @Test void 相同事件但摘要不同时拒绝覆盖() { EventEnvelope envelope = withHash(envelope("sha256:placeholder")); diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index d5c3efac4..72155a2ea 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -131,3 +131,21 @@ test('domestic event bridge SQL converges legacy tenant columns to the customer assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) }) + +test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { + const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') + assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) + assert.match(overseas, /USE baiyue_event_bridge_overseas;/) + for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { + assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) + } + for (const index of [ + 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', + 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', + 'uk_bridge_attempt', 'idx_bridge_attempt_event' + ]) assert.match(overseas, new RegExp(index)) + assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/) + assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/) + assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), + 'overseas Event Bridge schema must be initialized before Lastleg tables') +}) From cf5d603a215b8c05cd801efce33493a3644468c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:10:42 +0800 Subject: [PATCH 203/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- .../resources/db/phase1-admin-accounts.sql | 6 +- .../InitialAccountSeedSqlContractTest.java | 7 +- deploy/sql/README.md | 4 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 186 ++++-------------- firstleg/AGENTS.md | 3 +- .../db/admin-prototype-menu-permissions.sql | 14 +- .../main/resources/db/phase1-admin-rbac.sql | 80 +------- .../db/phase1-member-permissions.sql | 9 - .../main/resources/db/phase1-onboarding.sql | 31 ++- .../resources/db/rbac-role-management-v2.sql | 9 - ...rototypeMenuPermissionSqlContractTest.java | 7 +- .../InitialIdentitySeedSqlContractTest.java | 33 +++- .../resources/db/verify-phase1-admin-rbac.sql | 14 +- scripts/README.md | 2 +- scripts/admin-test-env-acceptance.mjs | 185 ++++++++++++++++- scripts/admin-test-env-modules/fee-items.mjs | 4 +- .../finance-demo-fixtures-contract.test.mjs | 10 +- .../finance-demo-fixtures.mjs | 36 ++-- ...und-measurement-box-code-contract.test.mjs | 4 +- .../inbound-measurement.mjs | 122 ++++++------ .../initialization-baseline-contract.test.mjs | 20 +- ...nt-qualification-fixture-contract.test.mjs | 34 ++++ .../merchant-qualification-fixture.mjs | 5 +- .../order-inbound-status-fixtures.mjs | 4 +- scripts/admin-test-env-modules/orders.mjs | 162 +++++++++++---- .../admin-test-env-modules/phase3-customs.mjs | 4 +- .../admin-test-env-modules/phase3-loading.mjs | 4 +- .../phase3-order-splitting.mjs | 4 +- .../admin-test-env-modules/supplemental.mjs | 10 +- scripts/admin-test-env-modules/suppliers.mjs | 4 +- scripts/agents-index-contract.ps1 | 64 +++++- scripts/client-phase2-test-env-acceptance.mjs | 6 +- scripts/client-test-env-acceptance.mjs | 6 +- ...verseas-acceptance-entry-contract.test.mjs | 2 +- 35 files changed, 664 insertions(+), 435 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fd132bd1..c35577029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ |---|---| | 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 | | 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 | -| Web 异常、请求关联 ID、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` 定位 | +| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 | | JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 | | Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 | | 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index cfa329c3d..130763a9f 100644 --- a/auth/src/main/resources/db/phase1-admin-accounts.sql +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -1,4 +1,4 @@ --- Phase-one local administrator accounts. +-- Phase-one local platform administrator account. Merchant accounts are created through onboarding. -- Development-only initial password: ChangeMe123! -- The BCrypt hash is intentionally stored instead of plaintext. USE baiyue_auth; @@ -10,9 +10,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), display_name = VALUES(display_name), diff --git a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java index 025bf6ec7..82620e90c 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialAccountSeedSqlContractTest { @Test - void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception { + void initializesOnlyThePlatformAdministratorAccount() throws Exception { assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") .getContentAsString(StandardCharsets.UTF_8)); assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); @@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest { assertThat(sql) .contains("(1000001, 'platform_admin'") .contains("'平台系统管理员', 'ACTIVE'") - .contains("(1000002, 'merchant_admin'") - .contains("'IT 测试客户管理员', 'ACTIVE'") .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") .doesNotContain("一期演示企业管理员"); } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f857cf41f..aabc7dd7d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,14 +3,14 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 - Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 22b4dabb6..3943ecee3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -278,7 +278,7 @@ PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_s EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; --- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 +-- 开发/测试环境最小登录基线只保留平台系统管理员;客户必须走正式建档/注册流程创建。 -- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, @@ -286,9 +286,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), account_status = 'ACTIVE', @@ -1194,10 +1192,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -1206,28 +1201,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -1235,8 +1212,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -1246,9 +1222,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -1335,39 +1309,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -1376,11 +1319,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -1396,17 +1334,10 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; -- ============================================================================ @@ -1455,17 +1386,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; @@ -1507,20 +1455,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN @@ -1548,9 +1487,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -1569,12 +1505,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' @@ -4286,23 +4216,9 @@ SET @unified_ddl = IF( PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; -- ============================================================================ --- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- 重新授权一期商户管理员全局模板,包含一期后续追加的菜单和按钮 -- ============================================================================ USE baiyue_iam; -INSERT INTO iam_permission ( - id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, route_path, http_method, sort_no, status, deleted -) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT owner_role.id, permission.id, 0 FROM iam_role owner_role @@ -15350,10 +15266,9 @@ VALUES (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), - (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), - (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), - (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0), + (5305000, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5305010, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5305011, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; @@ -15486,11 +15401,6 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance:settlement-ap:create', 'platform:finance:payment:create', 'platform:finance:settlement:void') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); -INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission - WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') -ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); - -- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id @@ -15956,22 +15866,18 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' -WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; @@ -16187,11 +16093,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局 Finance 权限模板不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; @@ -16199,11 +16102,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.id=4000100 AND r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员 Finance 模板授权不完整'; END IF; END$$ DELIMITER ; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 041535f39..36b45e079 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -32,7 +32,7 @@ | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | @@ -56,6 +56,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 4508de070..097bed02a 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -29,7 +29,9 @@ VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), - (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0), + (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,0), + (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,0), + (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,0), (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), @@ -101,18 +103,12 @@ UPDATE iam_permission p SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) - FROM iam_role r - JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' - WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index fcc5d4bde..0f00deb81 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,10 +26,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -38,28 +35,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -67,8 +46,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -78,9 +56,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -167,40 +143,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -209,11 +153,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -229,15 +168,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql index 8a88a7896..bddb3b44d 100644 --- a/iam/src/main/resources/db/phase1-member-permissions.sql +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -17,20 +17,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql index a31f31127..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -41,17 +41,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 5228b9b71..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -19,9 +19,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -40,12 +37,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2190fbe70..f8cd21e76 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -31,6 +31,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) + .contains("(5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement'") + .contains("(5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list'") + .contains("(5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view'") .contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'") .contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -60,8 +63,8 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("Finance 导出和下载中心授权不完整") - .contains("merchant:finance:statement:confirm") - .contains("客户全局管理员缺少对账单确认权限") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 86f26ec04..64008a223 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -11,25 +11,42 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialIdentitySeedSqlContractTest { @Test - void initializesPlatformAndItTestCustomerIdentitiesWithStableIds() throws Exception { - assertIdentitySeed(new ClassPathResource("db/phase1-admin-rbac.sql") - .getContentAsString(StandardCharsets.UTF_8)); - assertIdentitySeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + void initializesOnlyPlatformIdentityAndGlobalMerchantTemplates() throws Exception { + String identitySeed = new ClassPathResource("db/phase1-admin-rbac.sql") + .getContentAsString(StandardCharsets.UTF_8); + String templateSeed = new ClassPathResource("db/phase1-onboarding.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); + assertPlatformIdentitySeed(identitySeed); + assertPlatformIdentitySeed(unified); + assertGlobalMerchantTemplate(templateSeed); + assertGlobalMerchantTemplate(unified); } - private static void assertIdentitySeed(String sql) { + private static void assertPlatformIdentitySeed(String sql) { assertThat(sql) .contains("(0, 'PLATFORM', '虫虫出海平台'") - .contains("(10001, 'IT_TEST', 'IT 测试客户'") .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") - .contains("(2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员'") .contains("(3000001, 0, 2000001, 'EMPLOYEE'") - .contains("(3000002, 10001, 2000002, 'OWNER'") .contains("tenant_code = VALUES(tenant_code)") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(10001,") + .doesNotContain("(2000002,") + .doesNotContain("(3000002,") + .doesNotContain("4000101, 10001") .doesNotContain("PHASE1_DEMO") .doesNotContain("一期演示企业"); } + private static void assertGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'") + .contains("5300000, 0, 'MERCHANT', 'MENU'") + .contains("5301024, 0, 'MERCHANT', 'BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql index e2942472f..24dce2749 100644 --- a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -1,13 +1,17 @@ -- Any returned row represents a failed phase-one seed assertion. SELECT 'missing_auth_accounts' AS failure WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account - WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 + WHERE username = 'platform_admin' AND account_status = 'ACTIVE') <> 1 UNION ALL SELECT 'missing_active_identities' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u JOIN baiyue_iam.iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id IN (1000001, 1000002) - AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 + WHERE u.auth_user_id = 1000001 + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 1 +UNION ALL +SELECT 'unexpected_fixed_customer_seed' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_tenant WHERE tenant_code = 'IT_TEST') + OR EXISTS (SELECT 1 FROM baiyue_auth.auth_account WHERE username = 'merchant_admin') UNION ALL SELECT 'unexpected_permission_type' WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission @@ -34,10 +38,6 @@ SELECT 'platform_admin_not_fully_granted' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) UNION ALL -SELECT 'merchant_owner_not_fully_granted' -WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) - <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')) -UNION ALL SELECT 'missing_global_merchant_owner_template' WHERE NOT EXISTS ( SELECT 1 FROM baiyue_iam.iam_role diff --git a/scripts/README.md b/scripts/README.md index fac0cd8b6..360871a62 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1b6f37546..89bfa0a4c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -8,6 +8,8 @@ import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ + ['suppliers', ['merchant-qualification-fixture']], + ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], ['phase3-container-loading', ['inbound-measurement']], @@ -232,11 +234,20 @@ function usage() { --username 必填,管理员账号 --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} + --customer-name 指定本轮隔离客户名称,默认自动生成 + --merchant-username 复用既有已审核客户账号,不再创建客户 + --merchant-password 复用客户账号密码,不写入报告 + --origin-warehouse-name 固定国内起运仓名称 + --channel-name 固定物流渠道名称 + --channel-code 固定物流渠道编码 + --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed + --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 --output JSON 报告路径,默认 reports/ --help 显示帮助 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, -ADMIN_TEST_WEIGHT_IMAGE_PATH`) +ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, +MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } export function parseArguments(argv) { @@ -255,12 +266,27 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' + const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' + const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) + if (merchantUsername && merchantPassword) { + const fixtureIndex = modules.indexOf('merchant-qualification-fixture') + if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) + } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', - modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), + modules, + customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', + merchantUsername, + merchantPassword, + originWarehouseName: values.get('origin-warehouse-name') || '', + channelName: values.get('channel-name') || '', + channelCode: values.get('channel-code') || '', + orderProfileMode: values.get('order-profile-mode') || 'fixed', + waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1), output: values.get('output') || '', } } @@ -273,6 +299,32 @@ export function validateExecutionOptions(options) { && (!options.overseasGateway || options.overseasGateway.trim() === '')) { throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') } + if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { + throw new Error('wait-split-count必须是1-100的整数') + } + if (options.customerName && options.customerName.trim().length > 128) { + throw new Error('customer-name不能超过128个字符') + } + if (Boolean(options.merchantUsername) !== Boolean(options.merchantPassword)) { + throw new Error('merchant-username和merchant-password必须同时提供') + } + for (const [name, value] of [['origin-warehouse-name', options.originWarehouseName], + ['channel-name', options.channelName], ['channel-code', options.channelCode]]) { + if (value && value.trim().length > 128) throw new Error(`${name}不能超过128个字符`) + } + if (!['fixed', 'randomized'].includes(options.orderProfileMode)) { + throw new Error('order-profile-mode必须为fixed或randomized') + } + if (options.orderProfileMode === 'randomized' + && (!options.originWarehouseName || !options.channelName || !options.channelCode)) { + throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') + } + if (options.waitSplitCount > 1) { + const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement']) + if (options.modules.some((module) => !fixtureModules.has(module))) { + throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement') + } + } return options } @@ -555,6 +607,124 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + captureWaitSplitFixture(sequence) { + const order = this.createdResources.orders || {} + const measurement = this.createdResources.inboundMeasurement || {} + const ready = Boolean(order.orderId && measurement.inboundId + && String(order.tenantId) === String(measurement.tenantId) + && measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED') + this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready, + `tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`, + { scenario: 'fixed-customer-wait-split', deploymentBlocker: true }) + if (!ready) return false + this.createdResources.waitSplitFixtures ??= [] + this.createdResources.waitSplitFixtures.push({ + sequence, tenantId: String(measurement.tenantId), orderId: String(order.orderId), + orderNo: order.orderNo || null, inboundId: String(measurement.inboundId), + inboundCode: order.inboundCode || null, processStatus: measurement.processStatus, + reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null, + totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0), + palletCount: measurement.palletCount || null, + actualGrossWeightKg: measurement.actualGrossWeightKg || null, + actualNetWeightKg: measurement.actualNetWeightKg || null, + measurementProfile: this.context.waitSplitVariant?.measurementProfile || null, + receivingWarehouseId: order.receivingWarehouseId || null, + receivingWarehouseName: order.receivingWarehouseName || null, + destinationWarehouseId: order.destinationWarehouseId || null, + destinationWarehouseCode: order.destinationWarehouseCode || null, + destinationWarehouseName: order.destinationWarehouseName || null, + channelCode: order.channelCode || null, + channelName: order.channelName || null, + pricingTaxMode: order.pricingTaxMode || null, + destinationType: order.destinationType || null, + fulfillmentType: order.fulfillmentType || null, + pickupMethod: order.pickupMethod || null, + taxMode: order.taxMode || null, + containerMode: order.containerMode || null, + customsInspection: order.customsInspection ?? null, + }) + return true + } + + waitSplitVariant(sequence) { + const dimensionProfiles = [ + { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } }, + { code: 'MEDIUM', first: { length: 39.1, width: 29.1, height: 28.1 }, replacement: { length: 39.5, width: 29.5, height: 28.5 }, outer: { length: 44, width: 34, height: 34 } }, + { code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 }, outer: { length: 54, width: 44, height: 44 } }, + { code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 }, outer: { length: 64, width: 54, height: 54 } }, + ] + const skuCount = 1 + ((sequence - 1) % 4) + const packageCountPerSku = 1 + (Math.floor((sequence - 1) / 4) % 5) + const palletCount = 1 + ((sequence - 1) % 3) + const grossWeightPerPalletKg = 45 + ((sequence - 1) % 6) * 15 + return { + sequence, skuCount, packageCountPerSku, palletCount, grossWeightPerPalletKg, + measurementProfile: dimensionProfiles[(sequence - 1) % dimensionProfiles.length], + } + } + + randomizedOrderProfile(sequence) { + const combinations = [ + ['FBA', 'STANDARD'], + ['OVERSEAS', 'STANDARD'], + ['PRIVATE', 'STANDARD'], + ['FBA', 'ONE_CLICK_DROPSHIP'], + ['OVERSEAS', 'ONE_CLICK_DROPSHIP'], + ['PRIVATE', 'ONE_CLICK_DROPSHIP'], + ] + const [destinationType, fulfillmentType] = combinations[(sequence * 5 + 1) % combinations.length] + const pickupMethods = ['PICKUP', 'SELF_EXPRESS', 'SELF_TRUCK'] + return { + destinationType, + fulfillmentType, + pickupMethod: pickupMethods[(sequence * 7 + 2) % pickupMethods.length], + taxMode: sequence % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION', + containerMode: sequence % 2 === 0 ? 'FULL_CONTAINER' : 'LCL', + customsInspection: sequence % 4 === 0, + } + } + + applyWaitSplitVariant(sequence) { + const variant = this.waitSplitVariant(sequence) + this.context.waitSplitVariant = variant + this.context.phase3LoadingIncrementalDetailCount = variant.skuCount + this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku + const profile = this.options.orderProfileMode === 'randomized' + ? this.randomizedOrderProfile(sequence) + : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP', + taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } + this.context.orderProfile = profile + this.context.orderDestinationType = profile.destinationType + this.context.orderFulfillmentType = profile.fulfillmentType + this.context.waitSplitRoute = { + originWarehouseName: this.options.originWarehouseName || '', + channelName: this.options.channelName || '', + channelCode: this.options.channelCode || '', + destinationIndex: (sequence * 17 + 11), + taxModeIndex: sequence - 1, + } + } + + async createAdditionalWaitSplitFixtures() { + const total = this.options.waitSplitCount + if (total <= 1) return + const baseMarker = this.marker + const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + try { + for (let sequence = 2; sequence <= total; sequence += 1) { + this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` + this.applyWaitSplitVariant(sequence) + await this.executeModule('orders', () => orders.run(this)) + if (this.modules.at(-1)?.success !== true) break + await this.executeModule('inbound-measurement', () => measurement.run(this)) + if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break + } + } finally { + this.marker = baseMarker + } + } + async refreshSession(reason) { const response = await this.request('POST', '/api/auth/refresh', { refreshToken: this.refreshToken }, '') @@ -1184,6 +1354,11 @@ export class AcceptanceRun { } async run() { + if (this.options.merchantUsername && this.options.merchantPassword) { + process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername + process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword + } + if (this.options.waitSplitCount > 1) this.applyWaitSplitVariant(1) const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' @@ -1270,6 +1445,9 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + if (module === 'inbound-measurement' && this.captureWaitSplitFixture(1)) { + await this.createAdditionalWaitSplitFixtures() + } const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) .some((name) => name.startsWith('phase4-overseas-')) if (module === 'system' && laterOverseasModule) { @@ -1290,7 +1468,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + const forbiddenValues = [this.options.password, this.options.merchantPassword, + this.token, this.refreshToken, this.overseasToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index dd3472bdd..f943c9e96 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -5,8 +5,8 @@ const BASE = '/api/platform/v1/fee-items' const BASELINE_COUNT = 111 const DOMESTIC_COUNT = 84 const INTERNATIONAL_COUNT = 27 -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs index 6482476d2..a67d138ee 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs @@ -7,10 +7,12 @@ import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-demo-fixtures.mjs'), 'utf8') -test('演示数据只消费本轮固定 marker 和客户 10001', () => { +test('演示数据只消费本轮固定 marker 和动态正式客户 fixture', () => { assert.match(source, /const MARKER = 'FIN-DEMO-20260812'/) assert.match(source, /fixture\.marker !== runMarker/) - assert.match(source, /fixture\.customerId !== '10001'/) + assert.match(source, /customerId !== id\(qualificationFixture\?\.tenantId\)/) + assert.match(source, /context\.context\.merchantQualificationFixture/) + assert.doesNotMatch(source, /customerId:\s*['"]?10001|accountId=10001/) assert.match(source, /const runMarker = `\$\{MARKER\}-/) assert.match(source, /禁止借历史来源/) assert.doesNotMatch(source, /DELETE|cleanup|remove/i) @@ -41,8 +43,8 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => }) test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => { - assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=10001`/) - assert.doesNotMatch(source, /fee-details\?accountId=10001\/\$\{created\.id\}/) + assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=\$\{encodeURIComponent\(customerId\)\}`/) + assert.doesNotMatch(source, /fee-details\?accountId=.*\/\$\{created\.id\}/) }) test('费用来源使用 operationVersion,应付供应商长整型保持字符串精度', () => { diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index 4ce0c5dc7..255375182 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -30,10 +30,10 @@ function blocker(context, name, response, evidence, category) { }) } -async function waitForSources(context, sourceFeeIds) { +async function waitForSources(context, customerId, sourceFeeIds) { let pool = [] for (let attempt = 0; attempt < 30; attempt += 1) { - const response = await context.request('GET', `${BASE}/fee-pool?customerId=10001&direction=ALL`) + const response = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) pool = rows(data(response)).concat(rows(data(response)?.items)) const visible = new Set(pool.map((item) => id(item.sourceId))) if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool @@ -80,10 +80,11 @@ async function createExtraPayable(context, fixture, index) { unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', supplierId, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` - const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) + const customerId = id(fixture.customerId) + const createdResponse = await write(context, 'POST', `${basePath}?accountId=${encodeURIComponent(customerId)}`, body, `extra-payable-${index}`) const created = data(createdResponse) if (!ok(createdResponse) || !created?.id) return null - const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`) + const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=${encodeURIComponent(customerId)}`, undefined, `extra-payable-${index}-approve`) record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`) return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, @@ -91,7 +92,7 @@ async function createExtraPayable(context, fixture, index) { } async function createStatement(context, source, role, targetStatus) { - const body = { customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', + const body = { customerId: id(source.customerId), currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate, remark: `${MARKER}-${role}` } const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`) @@ -120,7 +121,7 @@ async function createAr(context, statement, role) { async function receive(context, fixture, settlement, amount, role) { const response = await write(context, 'POST', `${BASE}/receipts`, { - customerId: 10001, receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', + customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`, allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }], }, `receipt-${role}`) @@ -130,7 +131,7 @@ async function receive(context, fixture, settlement, amount, role) { async function createAp(context, fixture, source, role) { const response = await write(context, 'POST', `${BASE}/payable-settlements`, { - customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], + customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY', }, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`) const value = data(response) @@ -165,8 +166,9 @@ async function recoverExistingDemo(context, reportPath) { const report = JSON.parse(await readFile(reportPath, 'utf8')) const fixture = report?.createdResources?.financeFullFixtures const matrix = structuredClone(report?.createdResources?.financeDemo ?? {}) - if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || matrix.customerId !== '10001') { - throw new Error('恢复报告不是 FIN-DEMO-20260812 / 客户 10001 的同一轮 fixture') + if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER + || !id(matrix.customerId) || id(matrix.customerId) !== id(fixture.customerId)) { + throw new Error('恢复报告不是 FIN-DEMO-20260812 同一轮动态客户 fixture') } context.marker = matrix.runMarker await verifyUnfilteredLists(context) @@ -189,7 +191,7 @@ async function recoverExistingDemo(context, reportPath) { const role = roles[index] const detail = await sourceDetail(context, payableIds[index]) const source = detail.value - if (!ok(detail.response) || source?.direction !== 'PAYABLE' || source?.customerId !== '10001' + if (!ok(detail.response) || source?.direction !== 'PAYABLE' || id(source?.customerId) !== id(matrix.customerId) || !id(source?.sourceFeeId)) { blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response, `feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`, @@ -276,9 +278,12 @@ export async function run(context) { const producer = await import('./finance-full-fixtures.mjs') await producer.run(context) const fixture = context.createdResources.financeFullFixtures - if (!fixture || fixture.marker !== runMarker || fixture.customerId !== '10001') { + const qualificationFixture = context.context.merchantQualificationFixture + const customerId = id(fixture?.customerId) + if (!fixture || fixture.marker !== runMarker || !customerId + || customerId !== id(qualificationFixture?.tenantId)) { blocker(context, '取得演示企业真实 Finance fixture', null, - 'finance-full-fixtures 未返回 marker=FIN-DEMO-20260812、customerId=10001 的独立资源,禁止借历史来源', + 'finance-full-fixtures 未返回与 merchant-qualification-fixture 同客户的本轮独立资源,禁止借历史来源', 'missing-finance-demo-fixture') return } @@ -289,10 +294,10 @@ export async function run(context) { if (value) extraPayables.push(value) } const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables] - const pool = await waitForSources(context, produced.map((item) => item.sourceFeeId).filter(Boolean)) + const pool = await waitForSources(context, customerId, produced.map((item) => item.sourceFeeId).filter(Boolean)) const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const sources = produced.map((source) => ({ ...source, - feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, + feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, customerId, supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) })) .filter((source) => source.feeSourceId) const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') @@ -304,7 +309,8 @@ export async function run(context) { return } - const matrix = { marker: MARKER, runMarker, customerId: '10001', customerLabel: '一期演示企业 10001', + const matrix = { marker: MARKER, runMarker, customerId, + customerLabel: qualificationFixture?.tenantName || `动态验收客户 ${customerId}`, feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {}, voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations } diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 4704d790d..0fed93283 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -22,7 +22,9 @@ test('measurement submission confirms weight evidence before submit', () => { }) test('measurement fixture derives pallet resources from confirmed image count', () => { - assert.match(source, /palletCount: WEIGHING_IMAGE_COUNT/) + assert.match(source, /waitSplitVariant\?\.palletCount \|\| 1/) + assert.match(source, /palletIds: warehouseResourcesReady\.palletIds/) + assert.match(source, /confirmedRows\.length === palletIds\.length/) assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/) assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 6c2f7e14d..f68f7d3d0 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,7 +1,6 @@ const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const WEIGHING_IMAGE_COUNT = 1 const FIXTURE_PALLET_CAPACITY_CBM = 1.2 const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, @@ -44,16 +43,17 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` - const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { - warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, - weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, - }) - const palletId = asId(context.apiSuccess(MODULE, '创建称重托盘主数据', palletResponse, - `warehouseId=${warehouseId}`, OPTIONS)) - if (!palletId) return null - const resources = [] + const palletIds = [] for (let index = 1; index <= palletCount; index += 1) { + const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, + weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}-PALLET-${index}`, + }) + const palletId = asId(context.apiSuccess(MODULE, `创建称重托盘主数据 ${index}/${palletCount}`, + palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) + if (!palletId) return null + palletIds.push(palletId) const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` let locationId = null if (context.context?.phase3LoadingRequireSharedOverflow !== true) { @@ -75,54 +75,65 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount, palletCapacityCbm: FIXTURE_PALLET_CAPACITY_CBM, resources, } - return { palletId, palletCount, resources } + return { palletId: palletIds[0], palletIds, palletCount, resources } } -async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { - const uploadResponse = await context.requestMultipart('POST', - `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { - content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`, - }) - const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, - `inboundId=${inboundId}`, OPTIONS) - const fileId = asId(upload?.fileId) - if (!fileId) return null +async function createConfirmedWeightEvidence(context, { + inboundId, tenantId, palletIds, grossWeightPerPalletKg, +}) { + if (!Array.isArray(palletIds) || palletIds.length < 1) return null + const confirmedResults = [] + for (let index = 0; index < palletIds.length; index += 1) { + const palletId = palletIds[index] + const uploadResponse = await context.requestMultipart('POST', + `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { + content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-${index + 1}.png`, + }) + const upload = context.apiSuccess(MODULE, `上传称重图片并执行 OCR ${index + 1}/${palletIds.length}`, + uploadResponse, `inboundId=${inboundId}`, OPTIONS) + const fileId = asId(upload?.fileId) + if (!fileId) return null - const createResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { - fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, - ocrWeightValue: null, ocrWeightUnit: null, confidence: null, - }) - const created = context.apiSuccess(MODULE, '登记称重图片与托盘快照', createResponse, - `fileId=${fileId}, palletId=${palletId}`, OPTIONS) - if (!created?.id) return null + const createResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { + fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, + ocrWeightValue: null, ocrWeightUnit: null, confidence: null, + }) + const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`, + createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS) + if (!created?.id) return null - const confirmResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { - version: Number(created.version), adjustedWeightValue: 100, - adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, - }) - const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse, - `resultId=${created.id}, palletId=${palletId}`, OPTIONS) - const netWeightKg = Number(confirmed?.netWeightKg) - const grossWeightKg = Number(confirmed?.grossWeightKg) - if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { - return null + const grossWeight = Number(grossWeightPerPalletKg) + index * 5 + const confirmResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { + version: Number(created.version), adjustedWeightValue: grossWeight, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, + }) + const confirmed = context.apiSuccess(MODULE, `人工确认称重图片净重 ${index + 1}/${palletIds.length}`, + confirmResponse, `resultId=${created.id}, palletId=${palletId}`, OPTIONS) + const netWeightKg = Number(confirmed?.netWeightKg) + const grossWeightKg = Number(confirmed?.grossWeightKg) + if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { + return null + } + confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) } const workspaceResponse = await context.request('GET', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse, - `resultId=${created.id}`, OPTIONS) + `resultIds=${confirmedResults.map((result) => result.id).join(',')}`, OPTIONS) const confirmedRows = (workspace?.weightResults || []) .filter((result) => result?.recognitionStatus === 'CONFIRMED') - const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT - && asId(confirmedRows[0]?.id) === asId(created.id) - && asId(confirmedRows[0]?.palletId) === palletId + const grossWeightKg = confirmedResults.reduce((sum, result) => sum + result.grossWeightKg, 0) + const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0) + const persisted = confirmedRows.length === palletIds.length + && confirmedResults.every((expected) => confirmedRows.some((row) => + asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId)) && closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001) && closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001) context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, - `confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, + `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, OPTIONS) return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null } @@ -280,8 +291,8 @@ export async function run(context) { } const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}` - // Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm). - const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 } + const measurementProfile = context.context?.waitSplitVariant?.measurementProfile + const firstDimensions = measurementProfile?.first || { length: 59.1, width: 40.2, height: 30.3 } const firstShipmentId = `FBA-${context.marker.slice(-12)}-01` const firstScanResponse = await multipartRequest(context, scanPath, { boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId, @@ -325,7 +336,7 @@ export async function run(context) { && Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1, `version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS) - const replacement = { length: 59.5, width: 41.2, height: 31.3 } + const replacement = measurementProfile?.replacement || { length: 59.5, width: 41.2, height: 31.3 } const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02` const rescanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, scannedShipmentId: replacementShipmentId, @@ -342,8 +353,7 @@ export async function run(context) { let measuredResponse = await context.request('GET', measurementPath) let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) - // The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge. - const weightedOuter = { length: 64, width: 54, height: 54 } + const weightedOuter = measurementProfile?.outer || { length: 64, width: 54, height: 54 } const unitVolume = halfUp( weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000, 3) const totalVolume = unitVolume * originalPackageCount @@ -374,7 +384,7 @@ export async function run(context) { `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) const resetFirstCount = context.apiSuccess(MODULE, - '增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse, + '增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse, `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail @@ -409,10 +419,10 @@ export async function run(context) { `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) } measuredResponse = await context.request('GET', measurementPath) - measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS) + measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS) measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean)) - context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse, + context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse, detailIds.size >= incrementalDetailCount && measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount, `measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS) @@ -482,12 +492,13 @@ export async function run(context) { return } + const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1)) const warehouseResourcesReady = await provisionWarehouseResources(context, { - inboundId, warehouseId: asId(summary?.warehouseId), palletCount: WEIGHING_IMAGE_COUNT, + inboundId, warehouseId: asId(summary?.warehouseId), palletCount, }) context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse, Boolean(warehouseResourcesReady), - `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${WEIGHING_IMAGE_COUNT}`, + `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${palletCount}`, OPTIONS) if (!warehouseResourcesReady) { finishScenario(context, start) @@ -495,11 +506,12 @@ export async function run(context) { } const weightEvidence = await createConfirmedWeightEvidence(context, { - inboundId, tenantId, palletId: warehouseResourcesReady.palletId, + inboundId, tenantId, palletIds: warehouseResourcesReady.palletIds, + grossWeightPerPalletKg: Number(context.context?.waitSplitVariant?.grossWeightPerPalletKg || 100), }) context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse, Boolean(weightEvidence), - `confirmedImages=${weightEvidence?.count ?? 0}, expected=${WEIGHING_IMAGE_COUNT}`, + `confirmedImages=${weightEvidence?.count ?? 0}, expected=${palletCount}`, OPTIONS) if (!weightEvidence) { finishScenario(context, start) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 746730c2a..f462e5104 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -7,23 +7,23 @@ const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-adm const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') -test('统一初始化 SQL 保留最小登录与客户主体', () => { - assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) +test('统一初始化 SQL 只保留平台管理员且不创建固定客户', () => { + assert.match(sql, /最小登录基线只保留平台系统管理员/) assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) - assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) - assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) + assert.doesNotMatch(sql, /'merchant_admin'|'IT_TEST'|'IT 测试客户'/) + assert.doesNotMatch(iamSeed, /'IT_TEST'|'IT 测试客户'|\(10001,/) const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] assert.ok(accountSeed, 'missing auth account seed') assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, 're-running the unified SQL must not reset an existing account password') }) -test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { - for (const source of [sql, iamSeed]) { - assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /status = 'APPROVED'/) - } +test('统一初始化直接提供tenant0商户角色与权限模板', () => { + assert.match(sql, /4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'/) + assert.match(sql, /5300000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:enterprise'/) + assert.match(sql, /5301024, 0, 'MERCHANT', 'BUTTON',[\s\S]*?'merchant:order:quick-create'/) + assert.match(sql, /5305000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:finance:statement'/) + assert.match(sql, /r\.id=4000100[\s\S]*?客户全局管理员 Finance 模板授权不完整/) }) test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 6560cab8f..ac4d215a1 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') test('默认全量验收先准备已审核商户再运行订单链路', () => { const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' @@ -22,5 +23,38 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /item\.status === 'APPROVED'/) assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/) + assert.match(fixture, /context\.options\?\.customerName/) assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i) }) + +test('批量待分柜参数固定同一客户并限制为订单测量链', () => { + assert.match(acceptance, /--customer-name /) + assert.match(acceptance, /--merchant-username /) + assert.match(acceptance, /--origin-warehouse-name /) + assert.match(acceptance, /--channel-name /) + assert.match(acceptance, /--wait-split-count /) + assert.match(acceptance, /--order-profile-mode /) + assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) + assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) + assert.match(acceptance, /wait-split-count必须是1-100的整数/) + assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) + assert.match(acceptance, /createAdditionalWaitSplitFixtures/) + assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/) + assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) + assert.match(acceptance, /waitSplitVariant\(sequence\)/) + assert.match(acceptance, /packageCountPerSku/) + assert.match(acceptance, /grossWeightPerPalletKg/) + assert.match(acceptance, /randomizedOrderProfile\(sequence\)/) + assert.match(acceptance, /\['PRIVATE', 'STANDARD'\]/) + assert.match(acceptance, /ONE_CLICK_DROPSHIP/) + assert.match(acceptance, /destinationWarehouseCode: order\.destinationWarehouseCode/) + assert.match(acceptance, /channelName: order\.channelName/) + assert.match(acceptance, /pricingTaxMode: order\.pricingTaxMode/) + assert.match(orders, /customerId: profile\.id/) + assert.match(orders, /customerName: companyName/) + assert.match(orders, /\['FBA', 'OVERSEAS', 'PRIVATE'\]\.includes/) + assert.match(orders, /destinationType === 'PRIVATE'/) + assert.match(orders, /PRIVATE_ADDRESS/) + assert.match(orders, /eligible\.length < 1/) + assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 61c541603..a21a9053c 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -28,10 +28,11 @@ async function waitForDimensionInitialization(context, tenantId) { export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') + const customerName = String(context.options?.customerName || '').trim() const phone = await context.findAvailablePhone(MODULE) const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { - tenantName: `全量验收下单客户-${suffix}`, - legalName: `全量验收下单有限公司-${suffix}`, + tenantName: customerName || `全量验收下单客户-${suffix}`, + legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, legalRepresentative: '全量验收法人', contactName: '全量验收管理员', contactPhone: phone, diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index fdfe9b1b9..246386f9e 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -2,8 +2,8 @@ import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const ORDER_TARGETS = [ ['DRAFT', []], diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 3e7b83c41..d3d3c5954 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1,7 +1,7 @@ const MODULE = 'orders' const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const BOXES = [ { boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 }, { boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 }, @@ -21,8 +21,8 @@ function success(response) { return Boolean(response) && response.http === 200 & function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function requestedOrderDestinationType(context) { - return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS' - ? 'OVERSEAS' : 'FBA' + const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() + return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' } export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { const summary = orderDetail?.summary @@ -202,35 +202,88 @@ async function assertProductImageDownload(context, merchantToken, fileId) { async function loadMasterData(context, merchantToken) { const destinationType = requestedOrderDestinationType(context) + const fixedRoute = context.context?.waitSplitRoute || {} const domesticResponse = await request(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, `count=${records(domesticPage).length}`) - const destinationResponse = await request(context, 'GET', - `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, - undefined, merchantToken) - const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) - context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, - records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + let destinationResponse = null + let destinationPage = null + if (destinationType !== 'PRIVATE') { + destinationResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) + context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, + records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + } const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') + let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') + if (Number(destinationPage?.total) > destinations.length) { + const nextResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse) + destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')] + } + if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) { + throw new Error('缺少创建测试订单所需的收货仓或目的仓') + } - const pricingResult = await findPricedOrderRouteAcrossOrigins({ - receivings, destinations, - loadChannels: (receiving) => request(context, 'GET', + let pricingResult + if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) { + const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName) + if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`) + const channelsResponse = await request(context, 'GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, - undefined, merchantToken), - estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), - destinationType, - }) + undefined, merchantToken) + const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse) + const channel = (Array.isArray(channels) ? channels : []).find((item) => + (!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode) + && (!fixedRoute.channelName || item.channelName === fixedRoute.channelName)) + if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`) + const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : [] + if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制') + const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0 + const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] } + if (destinationType === 'PRIVATE') { + pricingResult = { receiving, priced: { channel: selectedChannel, + pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null }, + response: channelsResponse, attempts: 0, failures: [], originFailures: [] } + } else { + const detailResponse = await context.request('GET', + `/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`) + const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse) + const pricedCodes = new Set((detail?.rates || []) + .filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType) + .map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean)) + const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode))) + if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`) + const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length] + const result = await findPricedOrderRoute({ + receiving, destinations: [destination], channels: [selectedChannel], + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + pricingResult = { receiving, ...result, originFailures: [] } + } + } else { + pricingResult = await findPricedOrderRouteAcrossOrigins({ + receivings, destinations, + loadChannels: (receiving) => request(context, 'GET', + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, + undefined, merchantToken), + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + } const priced = pricingResult.priced const receiving = pricingResult.receiving context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), priced - ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` + ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), CONTRACT_OPTIONS) if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`) @@ -249,22 +302,39 @@ function buildPayload(context, profile, master, productImageFileId) { const shipmentId = `SHIP-${context.marker}`.slice(0, 64) const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` + const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length) + const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount)) + const packageCountPerSku = Math.max(1, + Number(context.context?.waitSplitVariant?.packageCountPerSku + || context.context?.phase3LoadingIncrementalPackageCount || 1)) + const selectedBoxes = BOXES.slice(0, skuCount) + const orderProfile = context.context?.orderProfile || {} + const isPrivate = master.destinationType === 'PRIVATE' + const pickupMethod = orderProfile.pickupMethod || 'PICKUP' + const receiver = isPrivate ? { + companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), + phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US', + province: 'New York', city: 'New York', district: 'Queens', + addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354', + } : null return { fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), - customerId: master.partner?.id ?? null, - customerName: master.partner?.partnerName ?? companyName, + customerId: profile.id, + customerName: companyName, transportMode: 'SEA', tradeTerm: 'FOB', - destinationCountryCode: master.destination.countryCode || 'US', - destinationWarehouseId: master.destination.id, - destinationWarehouseName: master.destination.warehouseName, + destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US', + destinationWarehouseId: master.destination?.id || null, + destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null), expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`, - pickupMethod: 'PICKUP', receivingWarehouseId: master.receiving.id, + pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP' + ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null, + receivingWarehouseId: master.receiving.id, receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, - carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', - pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', - customsInspection: false, destinationType: master.destinationType, - amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null, + carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION', + pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL', + customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType, + amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null, shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, sender: { @@ -272,11 +342,11 @@ function buildPayload(context, profile, master, productImageFileId) { email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000', }, - receiver: null, - cargoes: BOXES.map((box, index) => { + receiver, + cargoes: selectedBoxes.map((box, index) => { const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000 const unitsPerPackage = 10 + index - const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1 + const packageCount = packageCountPerSku return { skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, @@ -300,11 +370,12 @@ function buildPayload(context, profile, master, productImageFileId) { } function assertCargoContract(context, response, cargoes, payload) { - const complete = Array.isArray(cargoes) && cargoes.length === BOXES.length - && BOXES.every((box, index) => { - const actual = cargoes[index]; const expected = payload.cargoes[index] - return Number(actual?.lengthCm) === box.lengthCm && Number(actual?.widthCm) === box.widthCm - && Number(actual?.heightCm) === box.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) + const expectedCargoes = payload.cargoes || [] + const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length + && expectedCargoes.every((expected, index) => { + const actual = cargoes[index] + return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm + && Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) && actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName && actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode && Number(actual?.unitsPerPackage) === expected.unitsPerPackage @@ -320,7 +391,7 @@ function assertCargoContract(context, response, cargoes, payload) { && (actual?.referenceId ?? null) === (expected.referenceId ?? null) && actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker) }) - context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, + context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete, `cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`, CONTRACT_OPTIONS) } @@ -405,9 +476,18 @@ export async function run(context) { })), boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), + receivingWarehouseName: master.receiving.warehouseName, destinationType: master.destinationType, - destinationWarehouseId: id(master.destination.id), - destinationWarehouseCode: master.destination.warehouseCode, + destinationWarehouseId: id(master.destination?.id), + destinationWarehouseCode: master.destination?.warehouseCode || null, + destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null), + channelCode: master.channel.channelCode, + channelName: master.channel.channelName, + pricingTaxMode: master.pricingTaxMode, + pickupMethod: payload.pickupMethod, + taxMode: payload.taxMode, + containerMode: payload.containerMode, + customsInspection: payload.customsInspection, } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index de3230435..18a4d7be2 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -4,8 +4,8 @@ const MODULE = 'phase3-customs' const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true } const PLATFORM = '/api/platform/v1/customs' const MERCHANT = '/api/merchant/v1/customs' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 9059f6a91..060f643d0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -3,8 +3,8 @@ import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' function id(value) { return value == null ? '' : String(value) } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index d42339d61..eaddf6079 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,8 +1,8 @@ const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const CONTAINERS = '/api/platform/v1/containers' const SMART = '/api/platform/v1/first-leg/smart-loading' const PDA = '/api/platform/v1/first-leg/pda-loading' diff --git a/scripts/admin-test-env-modules/supplemental.mjs b/scripts/admin-test-env-modules/supplemental.mjs index 24a95aa72..28bef19f7 100644 --- a/scripts/admin-test-env-modules/supplemental.mjs +++ b/scripts/admin-test-env-modules/supplemental.mjs @@ -46,6 +46,10 @@ export async function run(context) { const module = 'supplemental' const scenario = 'service-health-and-auth-boundary' const blocker = { scenario, deploymentBlocker: true } + const customerId = String(context.context.merchantQualificationFixture?.tenantId || '') + context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId), + `customerId=${customerId || 'missing'}`, blocker) + if (!customerId) return // Health endpoints are part of the deployed service contract. Warehouse is // special: a schema/readiness failure is intentionally HTTP 503, not a fake @@ -102,7 +106,7 @@ export async function run(context) { } for (const [name, path] of [ ['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'], - ['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'], + ['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ]) { const response = await context.request('GET', path) context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), @@ -118,8 +122,8 @@ export async function run(context) { await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' }) } const resourceScopeChecks = [ - ['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'], - ['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'], + ['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], + ['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ['库位非法状态', `${locationBase}&status=ALLOCATED`], ] for (const [name, path] of resourceScopeChecks) { diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index abb38b91a..ae2b96255 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -1,8 +1,8 @@ const MODULE = 'suppliers' const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true } const BASE = '/api/platform/v1/suppliers' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index bc11e7d09..fe61a180d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -172,6 +172,48 @@ Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +# 高变化语义必须在最近指引与服务契约中保持一致。 +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' +Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' +Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' +$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' +Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约' +Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义' + +$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts') +$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue') +$adminOceanBookingViewTest = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts') +Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候选调用' +Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' +Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' + +# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。 +$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') +Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由' +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示' +Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由' + +# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 +$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') +Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口' +Assert-Contains $navigationRecovery 'if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true' 'Admin 未保存编辑保护' +Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin 普通路由错误边界' + +# 国内 PDA 的 H5 地址必须来自当前构建和同源代理,不能被历史登录缓存覆盖。 +$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') +$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') +$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' +Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' +Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' + +# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。 +$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports') +Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -189,7 +231,15 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue', 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', + 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue', + 'apps/admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', + 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/core/routing/navigationRecovery.ts', + 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue', 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/client-web/src/api/overseasHttp.ts', @@ -199,6 +249,9 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', 'apps/pda-uniapp/pages/tasks/tasks.vue', + 'apps/pda-uniapp/config/runtime.js', + 'apps/pda-uniapp/.env.example', + 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -210,6 +263,8 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', 'apps/pda-overseas-uniapp/src/api/overseas.js', 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', + 'apps/pda-overseas-uniapp/src/manifest.json', + 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', @@ -221,6 +276,9 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', @@ -243,6 +301,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', + 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', @@ -312,6 +373,7 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), (Join-Path $backendRoot 'iam/AGENTS.md'), (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-web/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), @@ -353,5 +415,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index b3981a78a..1815ee4d5 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -19,7 +19,7 @@ const args = parseArgs(process.argv.slice(2)) const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY ?? 'http://8.163.30.128:9000') const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME - ?? await promptText('二期客户端账号', 'merchant_admin') + ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD ?? await promptSecret('二期客户端密码') const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME @@ -30,8 +30,8 @@ const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000) -if (!clientPassword || !platformPassword) { - console.error('Client and platform passwords are required') +if (!clientUsername || !clientPassword || !platformPassword) { + console.error('An onboarded Client account and platform credentials are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 034057e97..b7afa8553 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -24,14 +24,14 @@ const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERN const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD ?? await promptSecret('平台审核密码') const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME - ?? await promptText('跨客户隔离测试账号', 'merchant_admin') + ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD ?? await promptSecret('跨客户隔离测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) -if (!platformPassword) { - console.error('Platform password is required') +if (!platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('Platform password and an independently onboarded client account are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 03fdfdd11..11b3dfb67 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -56,7 +56,7 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) }) test('dual-token refresh replaces the whole session only after a complete response', async () => { From 8eeb52ba77e85c34671fb558ebe7598afd22c9da Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:05:42 +0800 Subject: [PATCH 204/231] feat(firstleg): confirm domestic departure by container --- .../PlatformOutboundDispatchController.java | 30 +- .../dispatch/dto/OutboundDispatchDtos.java | 23 +- .../mapper/OutboundDispatchMapper.java | 5 + .../mapper/OutboundDispatchReadMapper.java | 36 ++- .../OutboundDispatchRepository.java | 17 +- .../service/OutboundDispatchService.java | 268 ++++++++++++------ .../openapi/phase3-outbound-dispatch-v1.yaml | 141 +++++---- ...utboundDispatchReadMapperContractTest.java | 35 ++- .../dispatch/OutboundDispatchServiceTest.java | 241 +++++++++------- .../OutboundDispatchSqlContractTest.java | 14 +- ...utboundDispatchControllerContractTest.java | 65 +++++ 11 files changed, 583 insertions(+), 292 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java index 36f8e7bd7..73e73b70c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query)); } - @GetMapping("/detail") + @GetMapping("/{containerBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId)); } - @GetMapping("/check-gate") + @GetMapping("/{containerBookingId}/gate") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')") - public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); + public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId)); } - @PostMapping("/confirm-dispatch") + @PostMapping("/{containerBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')") - @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓") + @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm( + containerBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/reverse-dispatch") + @PostMapping("/{containerBookingId}/reverse") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')") - @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓") + @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓") public ApiResponse reverse(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse( + containerBookingId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index 38c848ca6..b7c8dc1b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -10,21 +11,27 @@ import java.util.List; public final class OutboundDispatchDtos { private OutboundDispatchDtos() {} - public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {} + public record Query(@NotNull @Positive Long containerBookingId) {} - public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId, - @PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 500) String remark) {} public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} public record GateResult(boolean passed, List items, LocalDateTime checkedAt) {} - public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String cabinetNo, String containerNo, + public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode, + Long tenantId, Long customerId, String customerName, + String inboundStatus, Long pickTaskId, String pickTaskStatus) {} + + public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {} + public record ContainerGateResult(boolean passed, List inbounds, LocalDateTime checkedAt) {} + + public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus, - Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, + Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, - String dispatchStatus, Integer version, GateResult gate) {} + String dispatchStatus, Integer version, List inbounds, + ContainerGateResult gate) {} public record TimelineItem(String module, String action, String fromStatus, String toStatus, Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java index 09fb7a8a7..9bbcf97e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, @@ -22,36 +21,46 @@ public interface OutboundDispatchReadMapper { JOIN fl_container_booking booking ON booking.id=plan.container_booking_id JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 WHERE item.deleted=0 - AND inbound.order_id=#{orderId} - AND inbound.id=#{inboundId} - AND booking.id=#{containerBookingId} + AND booking.id=#{containerBookingId} ORDER BY inbound.id,booking.id - """) - List bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, - @Param("containerBookingId") Long containerBookingId); + List bindings(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, + inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, + inbound.customer_name customerName,inbound.process_status inboundStatus, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, + plan.id loadingPlanId,plan.plan_status loadingPlanStatus + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 + WHERE item.deleted=0 AND booking.id=#{containerBookingId} + ORDER BY inbound.id,booking.id + FOR UPDATE + """) + List bindingsForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" - SELECT booking.id,booking.status + SELECT booking.id,booking.status,booking.customer_id customerId FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} + WHERE allocation.container_booking_id=#{containerBookingId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); + OceanBinding ocean(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) - IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("tenantId") long tenantId); + IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN @@ -122,5 +131,6 @@ public interface OutboundDispatchReadMapper { Long customerId, String customerName, String inboundStatus, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} + record OceanBinding(Long id, String status, Long customerId) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index a0f87004a..3ad889820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.outbound.dispatch.repository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; @@ -22,15 +21,14 @@ public class OutboundDispatchRepository { @Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchReadMapper reads; - public List bindings(Query query) { - return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); + public List bindings(long containerId) { return reads.bindings(containerId); } + public List lockBindings(long containerId) { + return reads.bindingsForUpdate(containerId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { - return reads.ocean(containerId, tenantId); - } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { - return reads.truck(containerId, planId, tenantId); + public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); } + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { + return reads.truck(containerId, planId); } public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { return reads.evidence(tenantId, inboundId); @@ -38,6 +36,9 @@ public class OutboundDispatchRepository { public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); } + public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) { + return dispatches.findForUpdate(tenantId, inboundId, containerId); + } public void insert(OutboundDispatchEntity value) { dispatches.insert(value); } public int confirm(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.confirm(id, version, operatorId, operatorName, remark); 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 fad3762c1..4192ec0b2 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 @@ -51,93 +51,132 @@ public class OutboundDispatchService { } public List list(Query query) { - requireSelector(query); - return repository.bindings(query).stream().map(this::aggregate).toList(); + return List.of(aggregate(bindings(query.containerBookingId()))); } - public Detail detail(Query query) { - OutboundDispatchReadMapper.Binding binding = single(query); - Aggregate aggregate = aggregate(binding); - return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); + public Detail detail(long containerBookingId) { + List bindings = bindings(containerBookingId); + Aggregate aggregate = aggregate(bindings); + List timeline = bindings.stream() + .flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream()) + .distinct() + .sorted(Comparator.comparing(TimelineItem::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module)) + .toList(); + return new Detail(aggregate, timeline); } - public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); } + public ContainerGateResult checkGate(long containerBookingId) { + return aggregate(bindings(containerBookingId)).gate(); + } @Transactional - public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail confirm(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("CONFIRM", request); - Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query); + String hash = hash("CONFIRM", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash); if (replay != null) return replay; - Aggregate before = aggregate(binding); - if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); - OutboundDispatchEntity state = state(binding); - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单已确认派车"); + List bindings = lockedBindings(containerBookingId); + Aggregate before = aggregate(bindings); + if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁"); + List states = bindings.stream().map(this::stateLocked).toList(); + StateView visible = visibleState(states); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜已确认离仓"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { - throw concurrent(); + for (OutboundDispatchEntity state : states) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } } - history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), + OutboundDispatchEntity representative = states.get(0); + history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), - binding.containerBookingId(), operatorId, operatorName); + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); + } + carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, + operatorId, operatorName); } - if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) { + throw concurrent(); + } + return detail(containerBookingId); } @Transactional - public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail reverse(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("REVERSE", request); - Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query); + String hash = hash("REVERSE", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash); if (replay != null) return replay; - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单不在等待装船状态"); + List bindings = lockedBindings(containerBookingId); + List states = bindings.stream().map(this::existingStateLocked).toList(); + StateView visible = visibleState(states); + if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + 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 (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); + } + if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } } - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + for (OutboundDispatchEntity state : states) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } + } + OutboundDispatchEntity representative = states.get(0); + history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), + OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); + if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) { throw concurrent(); } - history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), - OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + return detail(containerBookingId); } - private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { - var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); - return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), - binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), - binding.loadingPlanId(), binding.loadingPlanStatus(), - id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), - dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), - dispatch == null ? 0 : dispatch.getVersion(), gate); + private Aggregate aggregate(List bindings) { + OutboundDispatchReadMapper.Binding first = validateContainer(bindings); + var pick = repository.pick(first.loadingPlanId()); + var ocean = repository.ocean(first.containerBookingId()); + var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + List inboundGates = new ArrayList<>(); + List inboundDtos = new ArrayList<>(); + List states = new ArrayList<>(); + for (OutboundDispatchReadMapper.Binding binding : bindings) { + var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); + GateResult gate = gate(binding, pick, ocean, truck, evidence); + inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); + inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), + binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), + binding.inboundStatus(), id(pick), status(pick))); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state != null) states.add(state); + } + StateView visible = visibleStateForRead(states, bindings.size()); + ContainerGateResult containerGate = new ContainerGateResult( + inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now()); + return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(), + first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck), + visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck, OutboundDispatchReadMapper.Evidence evidence) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), @@ -174,68 +213,125 @@ public class OutboundDispatchService { return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); } - private Detail replay(long operatorId, String operation, String key, String hash, Query query) { + private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { if (repository.claim(operatorId, operation, key, hash)) return null; var existing = repository.operation(operatorId, operation, key); if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { throw conflict("Idempotency-Key已用于不同请求"); } if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); - return detail(query); + return detail(containerBookingId); } - private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); if (state != null) return state; state = new OutboundDispatchEntity(); - state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); - state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); - state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); - try { repository.insert(state); } - catch (DuplicateKeyException ex) { - state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + state.setId(IdWorker.getId()); + state.setTenantId(binding.tenantId()); + state.setOrderId(binding.orderId()); + state.setInboundId(binding.inboundId()); + state.setContainerBookingId(binding.containerBookingId()); + state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); + state.setVersion(0); + try { + repository.insert(state); + } catch (DuplicateKeyException ex) { + state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); if (state == null) throw ex; } return state; } + private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state == null) throw conflict("当前货柜尚未确认离仓"); + return state; + } + private void history(OutboundDispatchEntity state, String action, String from, String to, String remark, long operatorId, String operatorName) { OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); - history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); - history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); - history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); + history.setId(IdWorker.getId()); + history.setDispatchId(state.getId()); + history.setTenantId(state.getTenantId()); + history.setInboundId(state.getInboundId()); + history.setContainerBookingId(state.getContainerBookingId()); + history.setAction(action); + history.setFromStatus(from); + history.setToStatus(to); + history.setRemark(remark); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.history(history); } - private OutboundDispatchReadMapper.Binding single(Query query) { - requireSelector(query); - List bindings = repository.bindings(query); - if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联"); - if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId"); - return bindings.get(0); + private List bindings(long containerBookingId) { + return normalize(repository.bindings(containerBookingId)); } - private static void requireSelector(Query query) { - if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { - throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个"); + private List lockedBindings(long containerBookingId) { + return normalize(repository.lockBindings(containerBookingId)); + } + + private static List normalize(List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定"); } + LinkedHashMap unique = new LinkedHashMap<>(); + for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value); + return List.copyOf(unique.values()); } + + private static OutboundDispatchReadMapper.Binding validateContainer(List bindings) { + OutboundDispatchReadMapper.Binding first = bindings.get(0); + boolean inconsistent = bindings.stream().anyMatch(binding -> + !Objects.equals(first.containerBookingId(), binding.containerBookingId()) + || !Objects.equals(first.loadingPlanId(), binding.loadingPlanId())); + if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突"); + return first; + } + + private static StateView visibleStateForRead(List states, int bindingCount) { + if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0); + if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理"); + return visibleState(states); + } + + private static StateView visibleState(List states) { + if (states.isEmpty()) throw conflict("货柜出仓状态不存在"); + String status = states.get(0).getStatus(); + Integer version = states.get(0).getVersion(); + if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus()) + || !Objects.equals(version, value.getVersion()))) { + throw conflict("货柜出仓状态或版本不一致,请联系管理员处理"); + } + return new StateView(status, version); + } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) { throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); } } - private static String hash(String action, ActionRequest request) { - String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|" - + request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); + + private static String hash(String action, long containerBookingId, ActionRequest request) { + String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|" + + Objects.toString(request.remark(), ""); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); - } catch (Exception ex) { throw new IllegalStateException(ex); } + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } + private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); } + private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); } private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); } + private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); } private static GateItem item(String code, String name, boolean pass, String actual, String requirement) { return new GateItem(code, name, pass, actual, requirement); } @@ -244,4 +340,6 @@ public class OutboundDispatchService { } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + + private record StateView(String status, Integer version) {} } 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 8553a2767..b590dfc68 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -1,85 +1,116 @@ openapi: 3.0.3 info: - title: Phase 3 Domestic Outbound Dispatch API - version: 1.0.0 + title: Phase 3 Container-level Domestic Outbound Dispatch API + version: 2.0.0 paths: /api/platform/v1/first-leg/outbound-dispatches: get: - summary: Query outbound aggregates by order, inbound or container + summary: Query the outbound aggregate for one container booking + description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed. + x-permission: platform:firstleg:outbound-dispatch:list parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdQuery'} responses: '200': - description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + description: Business envelope containing a single matching aggregate in a list content: application/json: schema: type: object properties: data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} - /api/platform/v1/first-leg/outbound-dispatches/detail: + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}: get: - summary: Query one aggregate and its unified timeline + summary: Query one container-level aggregate and unified timeline + x-permission: platform:firstleg:outbound-dispatch:view parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: '200': - description: Business envelope containing aggregate detail + description: Business envelope containing all inbound bindings and one visible dispatch version content: application/json: schema: type: object properties: - data: - type: object - properties: - aggregate: {$ref: '#/components/schemas/Aggregate'} - /api/platform/v1/first-leg/outbound-dispatches/check-gate: + data: {$ref: '#/components/schemas/Detail'} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: - summary: Evaluate the fail-closed domestic outbound gate + summary: Evaluate every inbound binding in the fail-closed container outbound gate + x-permission: platform:firstleg:outbound-dispatch:check parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: - '200': {description: Business envelope containing every gate item} - /api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: + '200': {description: Business envelope containing the container gate and per-inbound gate results} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm: post: - summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING + summary: Atomically confirm domestic departure for the entire container + description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible. + x-permission: platform:firstleg:outbound-dispatch:confirm parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope; gate or version failures use business error code} - /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: + '200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse: post: - 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. + summary: Atomically reverse the entire container before any actual shipping node exists + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH. + x-permission: platform:firstleg:outbound-dispatch:reverse parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope containing reverted aggregate} + '200': {description: Business envelope containing the reverted container aggregate} components: parameters: - OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} - InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} - ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + ContainerBookingIdQuery: + name: containerBookingId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + ContainerBookingIdPath: + name: containerBookingId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, maxLength: 128} schemas: Aggregate: type: object - required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate] + properties: + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: Stable cabinet number} + containerNo: {type: string, nullable: true, description: Real container number} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true} + truckAppointmentStatus: {type: string, nullable: true} + dispatchStatus: {type: string, description: One visible container-level status} + version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} + inbounds: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/InboundBinding'} + gate: {$ref: '#/components/schemas/ContainerGateResult'} + InboundBinding: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus] properties: orderId: {type: integer, format: int64} orderNo: {type: string} @@ -89,25 +120,35 @@ components: customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} inboundStatus: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string, description: 稳定柜次号} - containerNo: {type: string, nullable: true, description: 真实集装箱号} - loadingPlanId: {type: integer, format: int64} - loadingPlanStatus: {type: string} pickTaskId: {type: integer, format: int64, nullable: true} pickTaskStatus: {type: string, nullable: true} - oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} - dispatchStatus: {type: string} - version: {type: integer, minimum: 0} - gate: {type: object} + ContainerGateResult: + type: object + required: [passed, inbounds, checkedAt] + properties: + passed: {type: boolean} + inbounds: + type: array + items: + type: object + required: [inboundId, inboundCode, gate] + properties: + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + gate: {type: object} + checkedAt: {type: string, format: date-time} + Detail: + type: object + required: [aggregate, timeline] + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} + timeline: + type: array + description: Unified immutable facts; a container confirm or reverse contributes one visible history item. + items: {type: object} ActionRequest: type: object - required: [inboundId, containerBookingId, expectedVersion] + required: [expectedVersion] properties: - inboundId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} expectedVersion: {type: integer, minimum: 0} remark: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index d4f2eebfd..2a15df4bf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest { @Test void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { String sql = String.join("\n", OutboundDispatchReadMapper.class - .getMethod("bindings", Long.class, Long.class, Long.class) + .getMethod("bindings", long.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + assertThat(sql) + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("booking.id=#{containerBookingId}") + .doesNotContain("#{orderId}", "#{inboundId}"); + } + + @Test + void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindingsForUpdate", long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("booking.id=#{containerBookingId}") + .contains("item.deleted=0", "inbound.deleted=0") + .contains("FOR UPDATE") + .doesNotContain("LIMIT 1"); } @Test @@ -36,14 +52,19 @@ class OutboundDispatchReadMapperContractTest { } @Test - void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { - String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) + void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); - assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(oceanSql) + .contains("allocation.container_booking_id=#{containerBookingId}") + .contains("booking.customer_id customerId") + .doesNotContain("#{tenantId}"); + assertThat(truckSql) + .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .doesNotContain("#{tenantId}"); } @Test 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 8c4bf583f..e85d69d17 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 @@ -9,9 +9,11 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,159 +21,191 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OutboundDispatchServiceTest { - private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class); + private OutboundDispatchRepository repository; + + @BeforeEach + void setUp() { + repository = mock(OutboundDispatchRepository.class); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + } @Test - void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); - GateResult result = service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); - assertThat(result.items()).filteredOn(item -> !item.passed()) - .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + assertThat(result.inbounds()).hasSize(2); + assertThat(result.inbounds().get(1).gate().items()) + .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) + .extracting(GateItem::passed).containsExactly(false); } @Test - void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void detailReturnsOneVersionAndAllInboundBindings() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3); + OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatch(7L, 12L, 22L)).thenReturn(second); - service.checkGate(new Query(null, 11L, 22L)); + Detail detail = service.detail(22L); - verify(repository).ocean(22L, 7L); - verify(repository).truck(22L, 44L, 7L); - verify(repository, never()).ocean(22L, 8L); - verify(repository, never()).truck(22L, 44L, 8L); + assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L); + assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING"); + assertThat(detail.aggregate().version()).isEqualTo(3); } @Test - void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); - - service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); - - verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) - && "WAITING_FOR_LOADING".equals(history.getToStatus()))); - verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); - } - - @Test - void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0); + OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1); - service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok")); - verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + verify(repository).lockBindings(22L); + verify(repository).confirm(31L, 0, 9L, "admin", "ok"); + verify(repository).confirm(32L, 0, 9L, "admin", "ok"); + verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction()) + && value.getContainerBookingId().equals(22L))); + verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); } @Test - void confirmNeverWritesStateWhenAnyRealGateFails() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); + void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定"); + + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + verify(repository, never()).history(any()); + } + + @Test + void differingTechnicalRowVersionsAreRejectedAsContainerConflict() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("版本不一致"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() { 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); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1)); 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"); + assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked", + new ActionRequest(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()); - when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); - assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); + void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + AtomicReference requestHash = new AtomicReference<>(); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(3)); + return false; + }); + OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); + existing.setStatus("SUCCESS"); + when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> { + existing.setRequestHash(requestHash.get()); + return existing; + }); + + Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null)); + + assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true); - - assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", - new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); - } - - @Test - void reusedIdempotencyKeyWithDifferentHashIsRejected() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); + void reusedIdempotencyKeyWithDifferentPayloadIsRejected() { + OutboundDispatchService service = serviceWithoutCarrier(); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); - existing.setRequestHash("different"); existing.setStatus("SUCCESS"); + existing.setRequestHash("different"); + existing.setStatus("SUCCESS"); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing); - assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) - .hasMessageContaining("用于不同请求"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } - private void stubReadyChain() { - when(repository.bindings(any())).thenReturn(List.of(binding())); + private OutboundDispatchService serviceWithoutCarrier() { + return service(null); + } + + private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { + return new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + } + + private void stubContainer() { + when(repository.bindings(22L)).thenReturn(bindings()); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } - private static OutboundDispatchReadMapper.Binding binding() { - return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", + private static List bindings() { + return List.of(binding(11L, "I-11"), binding(12L, "I-12")); + } + + private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } + private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { + OutboundDispatchEntity value = new OutboundDispatchEntity(); + value.setId(id); + value.setTenantId(7L); + value.setOrderId(10L); + value.setInboundId(inboundId); + value.setContainerBookingId(22L); + value.setStatus(status); + value.setVersion(version); + return value; + } + private static OutboundDispatchGateContributor passing(String code) { return new OutboundDispatchGateContributor() { @Override public String code() { return code; } @@ -180,4 +214,5 @@ class OutboundDispatchServiceTest { } }; } + } 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 e587f38f1..2bba4fc07 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 @@ -38,17 +38,19 @@ class OutboundDispatchSqlContractTest { } @Test - void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { + void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception { String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); assertThat(openApi) .contains("outbound-dispatches:") - .contains("outbound-dispatches/detail:") - .contains("outbound-dispatches/check-gate:") - .contains("outbound-dispatches/confirm-dispatch:") - .contains("outbound-dispatches/reverse-dispatch:") + .contains("outbound-dispatches/{containerBookingId}:") + .contains("outbound-dispatches/{containerBookingId}/gate:") + .contains("outbound-dispatches/{containerBookingId}/confirm:") + .contains("outbound-dispatches/{containerBookingId}/reverse:") + .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") - .contains("Idempotency-Key"); + .contains("Idempotency-Key") + .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); } private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java new file mode 100644 index 000000000..83af6945e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOutboundDispatchControllerContractTest { + @Test + void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() { + assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/outbound-dispatches"); + Map permissions = Map.of( + "list", "platform:firstleg:outbound-dispatch:list", + "detail", "platform:firstleg:outbound-dispatch:view", + "checkGate", "platform:firstleg:outbound-dispatch:check", + "confirm", "platform:firstleg:outbound-dispatch:confirm", + "reverse", "platform:firstleg:outbound-dispatch:reverse"); + permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')")); + + assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}"); + assertThat(method("checkGate").getAnnotation(GetMapping.class).value()) + .containsExactly("/{containerBookingId}/gate"); + assertThat(method("confirm").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/confirm"); + assertThat(method("reverse").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/reverse"); + } + + @Test + void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() { + assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName())) + .containsExactly("expectedVersion", "remark") + .doesNotContain("inboundId", "containerBookingId"); + for (String name : List.of("confirm", "reverse")) { + BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class); + assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(audit.resourceId()).isEqualTo("#containerBookingId"); + } + } + + @Test + void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() { + for (String name : List.of("detail", "checkGate", "confirm", "reverse")) { + assertThat(Arrays.stream(method(name).getParameters()) + .filter(value -> value.isAnnotationPresent(PathVariable.class)) + .map(value -> value.getName())).containsExactly("containerBookingId"); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods()) + .filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + } +} From 1584c7a0a33c2b7dee47735abade9d8d0b8326ed Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 12:36:14 +0800 Subject: [PATCH 205/231] fix(firstleg): make truck status informational for departure --- .../service/OutboundDispatchService.java | 3 -- .../openapi/phase3-outbound-dispatch-v1.yaml | 4 +- .../dispatch/OutboundDispatchServiceTest.java | 37 +++++++++++++++++++ .../OutboundDispatchSqlContractTest.java | 2 + 4 files changed, 41 insertions(+), 5 deletions(-) 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 4192ec0b2..2ba9538d6 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 @@ -24,7 +24,6 @@ import java.util.*; @Service public class OutboundDispatchService { private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); - private static final Set TRUCK_READY = Set.of("DEPARTED", "COMPLETED"); private final OutboundDispatchRepository repository; private final Map contributors; @@ -187,8 +186,6 @@ public class OutboundDispatchService { status(pick), "COMPLETED")); items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次", ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED")); - items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()), - status(truck), "DEPARTED或COMPLETED")); boolean evidenceReady = evidence != null && evidence.evidenceCount() != null && evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1); items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady, 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 b590dfc68..f2dc216bb 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -99,8 +99,8 @@ components: loadingPlanStatus: {type: string} oceanBookingId: {type: integer, format: int64, nullable: true} oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} + truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} dispatchStatus: {type: string, description: One visible container-level status} version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} inbounds: 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 e85d69d17..3b5ec26d7 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 @@ -44,6 +44,34 @@ class OutboundDispatchServiceTest { .extracting(GateItem::passed).containsExactly(false); } + @Test + void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(null); + stubConfirmMutation("no-truck"); + + Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentId()).isNull(); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + + @Test + void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING")); + stubConfirmMutation("pending-truck"); + + Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null)); + + assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING"); + assertThat(result.aggregate().gate().passed()).isTrue(); + verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); + } + @Test void detailReturnsOneVersionAndAllInboundBindings() { OutboundDispatchService service = serviceWithoutCarrier(); @@ -185,6 +213,15 @@ class OutboundDispatchServiceTest { when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } + private void stubConfirmMutation(String key) { + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1); + } + private static List bindings() { return List.of(binding(11L, "I-11"), binding(12L, "I-12")); } 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 2bba4fc07..076c56f63 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 @@ -48,6 +48,8 @@ class OutboundDispatchSqlContractTest { .contains("outbound-dispatches/{containerBookingId}/reverse:") .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") + .contains("absence never blocks manual outbound confirmation") + .contains("PENDING or any non-terminal state never affects gate.passed") .contains("expectedVersion") .contains("Idempotency-Key") .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); From fc0a6ea7cacda2ce5512db4e475f3533c6dfff8d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 13:07:30 +0800 Subject: [PATCH 206/231] fix(firstleg): expose container documents and aggregate departure gates --- .../PlatformContainerController.java | 15 +++ .../PlatformLoadingListController.java | 21 ++++ .../customs/repository/CustomsRepository.java | 6 + .../CustomsDispatchGateContributor.java | 3 +- .../mapper/ContainerOverviewMapper.java | 2 +- .../mapper/OutboundDispatchReadMapper.java | 19 +++- .../OutboundDispatchRepository.java | 4 +- .../service/OutboundDispatchService.java | 22 ++-- .../firstleg/service/LoadingListService.java | 75 ++++++++++++- .../main/resources/openapi/firstleg-v1.yaml | 10 ++ .../resources/openapi/loading-lists-v1.yaml | 23 ++++ .../openapi/phase3-container-overview-v1.yaml | 10 +- .../openapi/phase3-outbound-dispatch-v1.yaml | 3 +- .../ContainerControllerContractTest.java | 5 + .../LoadingListControllerContractTest.java | 19 ++++ .../CustomsDispatchGateContributorTest.java | 5 +- ...utboundDispatchReadMapperContractTest.java | 15 ++- .../dispatch/OutboundDispatchServiceTest.java | 55 +++++++-- .../OutboundDispatchSqlContractTest.java | 3 + .../ContainerOverviewSqlContractTest.java | 8 ++ .../service/ContainerOverviewServiceTest.java | 17 ++- .../service/LoadingListServiceTest.java | 106 +++++++++++++++++- 22 files changed, 407 insertions(+), 39 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 0decb1063..cfbb44d48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -128,6 +128,21 @@ public class PlatformContainerController { return ApiResponse.success(loadingLists.containerClearance(id)); } + @GetMapping("/{id}/clearance/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + @Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。") + public ResponseEntity downloadClearance( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, + @PathVariable @Positive long fileId) { + FirstlegScopeGuard.platform(user); + var download = loadingLists.downloadContainerClearance(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @PostMapping("/{id}/clearance/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") @Operation(summary = "上传或替换货柜目的港清关资料") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 153ae3d48..803be7cda 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import org.springframework.http.MediaType; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; + /** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */ @Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询") @Validated @@ -124,6 +130,21 @@ public class PlatformLoadingListController { return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); } + @Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。") + @GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')") + public ResponseEntity downloadDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(download.contentType()).contentLength(download.content().length) + .body(download.content()); + } + @Operation(summary = "上传或替换装柜清单出口报关文档") @PostMapping("/loading-lists/{planId}/customs/document") @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index f33063e95..6bf93297d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,6 +81,12 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } + public CustomsCaseEntity clearedContainerCase(long containerBookingId) { + return cases.selectOne(new LambdaQueryWrapper() + .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) + .eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED") + .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() .eq(LoadingPlanEntity::getContainerBookingId, containerId)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index 94eb4cc89..ed2c87fc9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -18,8 +18,7 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(), - context.inboundId(), context.containerBookingId()); + CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId()); if (value == null) return gate(false, "MISSING"); return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 2614509e7..f19381c0c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity; import org.apache.ibatis.annotations.*; import java.util.List; @Mapper public interface ContainerOverviewMapper { - @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); + @Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List transport(@Param("id") long id); @Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List exceptions(@Param("id") long id); @Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 39b7d12e9..519cc03e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -63,13 +63,20 @@ public interface OutboundDispatchReadMapper { IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" - SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN - ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount, - MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL - AND seal_no<>'' THEN 1 ELSE 0 END) validSeal - FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} + SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL + AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') + THEN evidence.evidence_type END) evidenceCount, + MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL' + AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL + AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal + FROM fl_loading_plan plan + JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0 + JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id + AND evidence.tenant_id=item.account_id + WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId} """) - Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + Evidence evidence(@Param("containerBookingId") long containerBookingId, + @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM ( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index 3ad889820..cf510a1e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -30,8 +30,8 @@ public class OutboundDispatchRepository { public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { return reads.truck(containerId, planId); } - public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { - return reads.evidence(tenantId, inboundId); + public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) { + return reads.evidence(containerId, loadingPlanId); } public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); 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 2ba9538d6..0568fef73 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 @@ -23,7 +23,7 @@ import java.util.*; @Service public class OutboundDispatchService { - private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + private static final Set REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE"); private final OutboundDispatchRepository repository; private final Map contributors; @@ -152,12 +152,13 @@ public class OutboundDispatchService { var pick = repository.pick(first.loadingPlanId()); var ocean = repository.ocean(first.containerBookingId()); var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId()); + List containerContributorGates = contributorGates(first, ocean); List inboundGates = new ArrayList<>(); List inboundDtos = new ArrayList<>(); List states = new ArrayList<>(); for (OutboundDispatchReadMapper.Binding binding : bindings) { - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); + GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates); inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), @@ -175,8 +176,8 @@ public class OutboundDispatchService { } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck, - OutboundDispatchReadMapper.Evidence evidence) { + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, + List containerContributorGates) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), binding.inboundStatus(), "LOADED")); @@ -192,6 +193,13 @@ public class OutboundDispatchService { evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(), "PHOTO_STEP_1..4和含铅封号的SEAL")); + items.addAll(containerContributorGates); + return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + } + + private List contributorGates(OutboundDispatchReadMapper.Binding binding, + OutboundDispatchReadMapper.OceanBinding ocean) { + List items = new ArrayList<>(); var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(), binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean)); for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) { @@ -207,7 +215,7 @@ public class OutboundDispatchService { items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态")); } } - return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); + return List.copyOf(items); } private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { @@ -333,7 +341,7 @@ public class OutboundDispatchService { return new GateItem(code, name, pass, actual, requirement); } private static String contributorName(String code) { - return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期"; + return "报关协同完成"; } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 2b287f49d..fc96d5cf4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -55,7 +55,7 @@ public class LoadingListService { public static final String DELIVERY_ORDER = "DELIVERY_ORDER"; public static final String PICKUP_ORDER = "PICKUP_ORDER"; private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; - private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT"; + private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION"; private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER"; private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER"; @Resource @@ -128,6 +128,41 @@ public class LoadingListService { } catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} } + public DocumentDownload downloadContainerClearance(long bookingId, long fileId) { + requireBookingPlan(bookingId); + ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("目的港清关附件不存在或不属于当前货柜"); + } + String businessId = Long.toString(bookingId); + try { + var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("目的港清关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("目的港清关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {} private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); } private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; } @@ -169,6 +204,44 @@ public class LoadingListService { return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); } + public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION); + if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) { + throw notFound("出口报关附件不存在或不属于当前装柜计划"); + } + String businessId = Long.toString(planId); + try { + var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (metadata == null || !metadata.successful() || metadata.data() == null + || !Long.valueOf(fileId).equals(metadata.data().fileId()) + || !Long.valueOf(0).equals(metadata.data().tenantId()) + || !DECLARATION_FILE_TYPE.equals(metadata.data().businessType()) + || !businessId.equals(metadata.data().businessId())) { + throw notFound("出口报关附件不存在或归属不匹配"); + } + var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw notFound("出口报关附件不存在"); + } + MediaType contentType; + try { + contentType = MediaType.parseMediaType(metadata.data().contentType()); + } catch (RuntimeException exception) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + String fileName = StringUtils.hasText(document.getFileName()) + ? document.getFileName() : metadata.data().fileName(); + return new DocumentDownload(fileName, contentType, response.getBody()); + } catch (FeignException.NotFound exception) { + throw notFound("出口报关附件不存在或归属不匹配"); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + @Transactional @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "上传出口报关文档") diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 652cd8003..6b4dc5e8e 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,6 +356,16 @@ paths: required: true content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + summary: 下载货柜目的港清关附件 + description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + parameters: + - {$ref: '#/components/parameters/ContainerBookingId'} + - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store} + '404': {description: 当前附件不存在或归属不匹配} /api/platform/v1/containers/{id}/clearance/document/upload: post: summary: 直接选择文件上传或替换货柜目的港清关资料 diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 59c5fbc0d..18b436f15 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -99,6 +99,29 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} responses: '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download: + get: + summary: 下载装柜清单出口报关附件 + description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。 + x-permission: platform:firstleg:loading-list:list + parameters: + - {$ref: '#/components/parameters/PlanId'} + - {$ref: '#/components/parameters/WarehouseId'} + - name: fileId + in: path + required: true + description: File 服务长整型文件 ID + schema: {$ref: '#/components/schemas/LongId'} + responses: + '200': + description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store + headers: + Content-Disposition: {schema: {type: string}} + Cache-Control: {schema: {type: string, example: no-store}} + content: + application/octet-stream: + schema: {type: string, format: binary} + '404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配} /api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload: post: summary: 直接选择文件上传或替换装柜清单出口报关文档 diff --git a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml index 2b7420a4d..47bf7403f 100644 --- a/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-container-overview-v1.yaml @@ -36,6 +36,12 @@ paths: description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌' parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}} + /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: + get: + operationId: downloadContainerDestinationClearance + description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载' + parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}] + responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}} components: parameters: ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}} @@ -51,7 +57,7 @@ components: container: {$ref: '#/components/schemas/Container'} loadingPlanId: {type: integer, format: int64} pda: {$ref: '#/components/schemas/PdaSummary'} - documents: {type: array, items: {$ref: '#/components/schemas/Document'}} + documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}} exception: {$ref: '#/components/schemas/ExceptionState'} exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}} timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}} @@ -59,7 +65,7 @@ components: PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}} PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}} Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}} - Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}} + Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}} ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}} ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}} 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 f2dc216bb..aeeeb7b18 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,6 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} @@ -98,7 +99,7 @@ components: loadingPlanId: {type: integer, format: int64} loadingPlanStatus: {type: string} oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} + oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule} truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation} truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed} dispatchStatus: {type: string, description: One visible container-level status} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index d94c7e2b7..33327afb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -33,6 +33,7 @@ class ContainerControllerContractTest { assertPermission("logisticsDocument", "platform:firstleg:container:list"); assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit"); assertPermission("downloadDocument", "platform:firstleg:container:list"); + assertPermission("downloadClearance", "platform:firstleg:customs:manage"); } @Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception { @@ -40,6 +41,8 @@ class ContainerControllerContractTest { .getContentAsString(StandardCharsets.UTF_8); assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:", "/documents/{documentType}/{fileId}/download:", "ExceptionRequest", + "/clearance/document/{fileId}/download:", + "DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER", "enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:", "/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:", "AWAITING_REVIEW", "version: {type: integer, minimum: 0}", @@ -77,6 +80,8 @@ class ContainerControllerContractTest { .doesNotContain("tenantId") .doesNotContain("accountId"); assertThat(openapi) + .contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:") + .contains("Content-Disposition 与 Cache-Control no-store") .contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]") .contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus") .contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index d228c6137..28be884a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.http.ResponseEntity; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -41,6 +43,8 @@ class LoadingListControllerContractTest { ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class); + authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, + long.class, long.class, long.class); authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, long.class, long.class, String.class, DocumentAttachRequest.class); authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, @@ -88,6 +92,8 @@ class LoadingListControllerContractTest { .contains("oceanBookingStatus") .contains("pdaTaskId") .contains("cabinetNo") + .contains("LOADING_CUSTOMS_DECLARATION") + .contains("Cache-Control 为 no-store") .doesNotContain("internalContainerNo") .doesNotContain("rollback") .doesNotContain("delete:") @@ -118,6 +124,18 @@ class LoadingListControllerContractTest { assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); } + @Test + void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration", + AuthenticatedUser.class, long.class, long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly( + "/loading-lists/{planId}/customs/document/{fileId}/download"); + assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class); + assertThat(method.getParameters()[1].getName()).isEqualTo("planId"); + assertThat(method.getParameters()[2].getName()).isEqualTo("fileId"); + assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId"); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") @@ -134,6 +152,7 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-lists/{planId}", "/api/platform/v1/first-leg/loading-lists/{planId}/customs", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload", "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index 6695ea5d8..be11e6816 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -24,15 +24,16 @@ class CustomsDispatchGateContributorTest { @Test void passesOnlyCanonicalClearanceCompletedCase() { CustomsCaseEntity value = new CustomsCaseEntity(); - value.setStatus("RECEIPT_AVAILABLE"); - when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value); assertThat(contributor.assess(context()).passed()).isFalse(); value.setStatus("CLEARANCE_COMPLETED"); + when(repository.clearedContainerCase(81L)).thenReturn(value); assertThat(contributor.assess(context())) .satisfies(item -> { assertThat(item.passed()).isTrue(); assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); }); + verify(repository, times(2)).clearedContainerCase(81L); + verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); } private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 2a15df4bf..1efcea3a3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -47,10 +47,23 @@ class OutboundDispatchReadMapperContractTest { .getAnnotation(Select.class).value()[0]; assertThat(sql) - .contains("seal_no<>''") + .contains("evidence.seal_no<>''") .doesNotContain("<", ">"); } + @Test + void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception { + String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class) + .getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}") + .contains("item.loading_plan_id=plan.id", "item.deleted=0") + .contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id") + .contains("COUNT(DISTINCT CASE", "evidence.evidence_type") + .doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}"); + } + @Test void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) 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 3b5ec26d7..09c38aef4 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 @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test; import java.util.List; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.LongStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -30,18 +31,38 @@ class OutboundDispatchServiceTest { } @Test - void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() { + void containerEvidenceFailureFailsEveryBindingConsistently() { OutboundDispatchService service = serviceWithoutCarrier(); stubContainer(); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); assertThat(result.inbounds()).hasSize(2); - assertThat(result.inbounds().get(1).gate().items()) + assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) - .extracting(GateItem::passed).containsExactly(false); + .extracting(GateItem::passed).containsExactly(false)); + verify(repository, times(1)).evidence(22L, 44L); + } + + @Test + void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() { + OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class); + when(customs.code()).thenReturn("CUSTOMS_CLEARANCE"); + when(customs.assess(any())).thenReturn(new GateItem( + "CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED")); + OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null); + List tenBindings = LongStream.rangeClosed(11L, 20L) + .mapToObj(id -> binding(id, "I-" + id)).toList(); + stubContainer(tenBindings); + + ContainerGateResult result = service.checkGate(22L); + + assertThat(result.passed()).isTrue(); + assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue()); + verify(repository, times(1)).evidence(22L, 44L); + verify(customs, times(1)).assess(any()); } @Test @@ -72,6 +93,19 @@ class OutboundDispatchServiceTest { verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull()); } + @Test + void confirmAllowsMissingSailingScheduleContributor() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + stubConfirmMutation("no-schedule"); + + Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null)); + + assertThat(result.aggregate().gate().passed()).isTrue(); + assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items()) + .extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE")); + } + @Test void detailReturnsOneVersionAndAllInboundBindings() { OutboundDispatchService service = serviceWithoutCarrier(); @@ -117,7 +151,7 @@ class OutboundDispatchServiceTest { OutboundDispatchService service = serviceWithoutCarrier(); stubContainer(); when(repository.lockBindings(22L)).thenReturn(bindings()); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) @@ -201,16 +235,19 @@ class OutboundDispatchServiceTest { private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { return new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules); } private void stubContainer() { - when(repository.bindings(22L)).thenReturn(bindings()); + stubContainer(bindings()); + } + + private void stubContainer(List values) { + when(repository.bindings(22L)).thenReturn(values); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); - when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); - when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } private void stubConfirmMutation(String key) { 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 076c56f63..024cefac7 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 @@ -50,6 +50,9 @@ class OutboundDispatchSqlContractTest { .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") + .contains("Loading evidence is evaluated once for the physical container/loading plan") + .contains("Customs clearance is also resolved once at container scope") + .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index afecb8b9e..b19652ad9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -12,6 +12,14 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class ContainerOverviewSqlContractTest { + @Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception { + String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'") + .contains("ORDER BY document_type"); + } + @Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception { String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class) .getAnnotation(Select.class).value()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 0a7ea777f..114968d92 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -47,7 +47,13 @@ class ContainerOverviewServiceTest { 71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()), new ContainerOverviewMapper.PhotoRow( 72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now()))); - when(mapper.documents(41L)).thenReturn(java.util.List.of()); + com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L); + customs.setFileName("customs.pdf"); customs.setVersion(3); + com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity(); + clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L); + clearance.setFileName("clearance.pdf"); clearance.setVersion(1); + when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance)); when(mapper.exceptions(41L)).thenReturn(java.util.List.of()); when(mapper.transport(41L)).thenReturn(java.util.List.of()); when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of()); @@ -66,6 +72,15 @@ class ContainerOverviewServiceTest { assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN"); assertThat(count.count()).isEqualTo(1); }); + assertThat(overview.documents()).extracting(document -> document.documentType()) + .containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType())) + .singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3)); + assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType())) + .singleElement().satisfies(document -> { + assertThat(document.fileId()).isEqualTo(106L); + assertThat(document.version()).isEqualTo(1); + }); } @Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index aac76e2e6..8d2e33874 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -450,16 +450,16 @@ class LoadingListServiceTest { String contentHash = sha256Bytes(content); var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content); var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, - "LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf", + "LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf", (long) content.length); ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false); saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash); when(repository.aggregate(10L)).thenReturn(plan); when(repository.history(anyString())).thenReturn(null); when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved); - when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata)); - when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata)); when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); @@ -467,10 +467,108 @@ class LoadingListServiceTest { 10L, 100L, 9L, "operator", "customs-upload", 0, null, file); assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION); - verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"), + verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), eq("10"), eq(file)); } + @Test + void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() { + long planId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + plan.setPlanId(planId); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + document.setFileId(fileId); + document.setFileName("出口报关单.pdf"); + byte[] content = new byte[]{1, 2, 3}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(), + "application/pdf", (long) content.length); + when(repository.aggregate(planId)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadLoadingDeclaration(planId, 100L, fileId); + + assertThat(result.fileName()).isEqualTo("出口报关单.pdf"); + assertThat(result.content()).containsExactly(1, 2, 3); + verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("9007199254740993"), eq(fileId)); + } + + @Test + void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)) + .thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() { + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document); + var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L, + "LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(), + "application/pdf", 3L); + when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"), + eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata)); + + assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void downloadsCurrentDestinationClearanceWithExactContainerOwnership() { + long bookingId = 9_007_199_254_740_993L; + long fileId = 9_007_199_254_740_995L; + AggregateRow plan = row("COMPLETED", 4); + ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true); + document.setContainerBookingId(bookingId); + document.setFileId(fileId); + document.setFileName("destination-clearance.pdf"); + byte[] content = new byte[]{4, 5, 6}; + var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L, + "CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf", + hash(), "application/pdf", (long) content.length); + when(repository.aggregateByBooking(bookingId)).thenReturn(plan); + when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content)); + + var result = service.downloadContainerClearance(bookingId, fileId); + + assertThat(result.fileName()).isEqualTo("destination-clearance.pdf"); + assertThat(result.content()).containsExactly(4, 5, 6); + } + + @Test + void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true)); + + assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND); + verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong()); + } + @Test void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() { AggregateRow plan = row("LOADING", 2); From 4398e8b347ca8ce71a56b99b395a60c31ca779fc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 13:58:33 +0800 Subject: [PATCH 207/231] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- .../overseas/warehouse-overseas-service.yml | 5 + deploy/docker-compose.overseas.yml | 1 + deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 22 +- deploy/sql/海外区域统一增量-MySQL8.sql | 6 + file/pom.xml | 7 +- .../InternalBusinessFileController.java | 15 ++ .../baiyue/file/mapper/FileObjectMapper.java | 9 + .../file/service/BusinessFileStorage.java | 10 + .../service/InternalBusinessFileService.java | 13 +- .../InternalBusinessFileControllerTest.java | 14 +- .../InternalBusinessFileServiceTest.java | 39 +++- .../client/LoadingDocumentFileClient.java | 7 + .../customs/mapper/CustomsCaseMapper.java | 8 +- .../customs/repository/CustomsRepository.java | 7 +- .../CustomsDispatchGateContributor.java | 8 +- .../repository/LoadingListRepository.java | 6 +- .../firstleg/service/LoadingListService.java | 43 +++- ...ase3-simple-customs-document-migration.sql | 4 +- .../db/phase3-smart-loading-schema.sql | 4 +- .../openapi/phase3-outbound-dispatch-v1.yaml | 2 +- .../CustomsDispatchGateContributorTest.java | 42 +++- .../OutboundDispatchSqlContractTest.java | 3 +- .../LoadingListQueryMapperContractTest.java | 15 ++ .../SmartLoadingSqlContractTest.java | 8 +- .../service/LoadingListServiceTest.java | 125 +++++++++++ lastleg-service/AGENTS.md | 5 +- .../controller/InboundPlanController.java | 5 +- .../service/InboundPlanService.java | 4 + .../mapper/InventoryBalanceMapper.java | 10 + .../service/LoadingCompletedConsumer.java | 12 +- .../service/InboundPlanServiceTest.java | 7 + .../service/LoadingCompletedConsumerTest.java | 10 +- scripts/admin-test-env-modules/README.md | 2 + .../initialization-baseline-contract.test.mjs | 3 + .../批量待分柜数据生成指南.md | 155 +++++++++++++ scripts/synchronous-dependency-baseline.mjs | 3 + .../synchronous-dependency-contract.test.mjs | 11 +- warehouse-overseas-service/AGENTS.md | 15 +- warehouse-overseas-service/pom.xml | 4 + .../BaiyueWarehouseOverseasApplication.java | 2 + .../DomesticWarehouseDirectoryClient.java | 25 +++ ...ternalWarehouseResourceLockController.java | 6 +- .../MerchantWarehouseController.java | 67 ------ .../PlatformWarehouseController.java | 171 --------------- .../dto/WarehouseDetailResponse.java | 19 -- .../warehouse/dto/WarehouseRequests.java | 120 ---------- .../entity/ServiceCapabilityEntity.java | 57 ----- .../entity/WarehouseAddressEntity.java | 77 ------- .../warehouse/entity/WarehouseEntity.java | 71 ------ .../mapper/ServiceCapabilityMapper.java | 16 -- .../mapper/WarehouseAddressMapper.java | 16 -- .../warehouse/mapper/WarehouseMapper.java | 6 - .../repository/WarehouseRepository.java | 137 ------------ .../WarehouseResourceRepository.java | 19 -- .../service/DomesticWarehouseDirectory.java | 69 ++++++ .../service/WarehouseApplicationService.java | 206 ------------------ .../service/WarehouseResourceService.java | 30 +-- .../src/main/resources/application.yml | 4 + .../src/main/resources/db/warehouse.sql | 145 +----------- .../MerchantWarehouseControllerTest.java | 36 --- .../PlatformWarehouseControllerTest.java | 83 ------- .../repository/WarehouseRepositoryTest.java | 42 ---- .../SchemaChineseCommentContractTest.java | 5 +- ...rehouseUnifiedIncrementalContractTest.java | 3 + .../DomesticWarehouseDirectoryTest.java | 63 ++++++ .../WarehouseApplicationServiceTest.java | 109 --------- .../service/WarehouseResourceServiceTest.java | 38 +--- .../InternalWarehouseController.java | 16 ++ .../dto/SharedOverseasWarehouseResponse.java | 19 ++ .../service/WarehouseApplicationService.java | 27 +++ .../db/warehouse-public-master-data.sql | 14 +- .../InternalWarehouseControllerTest.java | 14 ++ .../WarehouseSchemaContractTest.java | 8 +- .../WarehouseApplicationServiceTest.java | 19 ++ .../verify-warehouse-public-master-data.sql | 32 +++ ...house-public-master-data-contract.test.mjs | 5 +- 78 files changed, 920 insertions(+), 1549 deletions(-) create mode 100644 scripts/admin-test-env-modules/批量待分柜数据生成指南.md create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/DomesticWarehouseDirectory.java delete mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/MerchantWarehouseControllerTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/repository/WarehouseRepositoryTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/DomesticWarehouseDirectoryTest.java delete mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseApplicationServiceTest.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/SharedOverseasWarehouseResponse.java diff --git a/AGENTS.md b/AGENTS.md index c35577029..2b039d350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | -| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml index a05d57b33..53f24467e 100644 --- a/config/nacos/overseas/warehouse-overseas-service.yml +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -4,3 +4,8 @@ spring: username: ${WAREHOUSE_DB_USERNAME} password: ${WAREHOUSE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index aae9708e7..24729ca6a 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -80,6 +80,7 @@ services: WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} event-bridge-service: <<: *overseas-common diff --git a/deploy/sql/README.md b/deploy/sql/README.md index aabc7dd7d..5a946294c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -10,7 +10,7 @@ - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3943ecee3..e5963fffd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5899,7 +5899,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5915,7 +5915,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -6356,7 +6356,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -6365,7 +6365,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -7012,11 +7016,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; @@ -15776,7 +15780,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15787,7 +15791,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7504a0f81..f7a4db970 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -969,6 +969,12 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。 +-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。 +DROP TABLE IF EXISTS wh_service_capability; +DROP TABLE IF EXISTS wh_warehouse_address; +DROP TABLE IF EXISTS wh_warehouse; + CREATE TABLE IF NOT EXISTS wh_pallet_operation ( id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', diff --git a/file/pom.xml b/file/pom.xml index 7c0efeae4..dabcc9edb 100644 --- a/file/pom.xml +++ b/file/pom.xml @@ -14,7 +14,12 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel - org.springframework.cloudspring-cloud-starter-openfeign + + org.springframework.cloudspring-cloud-starter-openfeign + + io.github.openfeign.formfeign-form-spring + + org.springframework.cloudspring-cloud-starter-loadbalancer org.springframework.bootspring-boot-starter-actuator org.apache.rocketmqrocketmq-spring-boot-starter diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 046b1ac3a..05d90c270 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -74,6 +75,20 @@ public class InternalBusinessFileController { .body(value.content()); } + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + public ApiResponse delete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + service.delete(tenantId, businessType, businessId, fileId, operatorId); + return ApiResponse.success(null); + } + private void check(String token) { if (token == null || !MessageDigest.isEqual( token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) { diff --git a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java index 729476614..f0faec11e 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java @@ -3,7 +3,16 @@ package com.baiyue.file.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.file.entity.FileObjectEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; @Mapper public interface FileObjectMapper extends BaseMapper { + @Update("UPDATE file_object SET status='VOID',deleted=1,updated_by=#{operatorId}," + + "updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{fileId} AND tenant_id=#{tenantId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} AND status='ACTIVE' " + + "AND deleted=0 AND version=#{version}") + int voidOwned(@Param("tenantId") long tenantId, @Param("businessType") String businessType, + @Param("businessId") String businessId, @Param("fileId") long fileId, + @Param("version") int version, @Param("operatorId") long operatorId); } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 5087c9ae9..f528b7cda 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -65,6 +65,16 @@ public class BusinessFileStorage { } } + boolean delete(String objectKey) { + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) return false; + try { + return Files.deleteIfExists(target); + } catch (IOException exception) { + return false; + } + } + private void verifyContent(MultipartFile file, String type) { try (InputStream input = file.getInputStream()) { byte[] prefix = input.readNBytes(4096); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 86268a298..7ac68d48b 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -7,6 +7,7 @@ import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; @@ -20,7 +21,7 @@ public class InternalBusinessFileService { "CUSTOMS_RECEIPT", "FINANCE_VOUCHER", "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DOCUMENT", + "LOADING_CUSTOMS_DECLARATION", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", "OVERSEAS_UNLOAD_PHOTO", @@ -70,6 +71,16 @@ public class InternalBusinessFileService { return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); } + @Transactional + public void delete(long tenantId, String businessType, String businessId, long fileId, long operatorId) { + if (operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "操作人必须有效"); + FileObjectEntity value = required(tenantId, businessType, businessId, fileId); + int changed = files.voidOwned(tenantId, businessType, businessId, fileId, + value.getVersion(), operatorId); + if (changed != 1) throw new BizException(ResultCode.CONFLICT, "业务附件已变化,请刷新后重试"); + storage.delete(value.getObjectKey()); + } + private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) { validateScope(tenantId, businessType, businessId, fileId); FileObjectEntity value = files.selectOne(new LambdaQueryWrapper() diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 96c7bb064..65ea3cbae 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -35,14 +35,22 @@ class InternalBusinessFileControllerTest { } @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { - when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + when(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L)) .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); - var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); assertThat(response.getBody()).containsExactly(1, 2); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); + } + + @Test void exactOwnershipCompensationDeleteRemainsBehindInternalToken() { + var response = controller.delete("internal-token", 7L, 0L, + "CONTAINER_DELIVERY_ORDER", "41", 91L); + + assertThat(response.successful()).isTrue(); + verify(service).delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); } @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index f0cc94a06..2840fa856 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -47,7 +47,7 @@ class InternalBusinessFileServiceTest { }); var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); - var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); @@ -57,7 +57,7 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, - InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DECLARATION", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, @@ -119,22 +119,47 @@ class InternalBusinessFileServiceTest { @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); - value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setBusinessType("LOADING_CUSTOMS_DECLARATION"); value.setBusinessId("51"); value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); when(files.selectOne(any())).thenReturn(value, value, null, null, null); when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); - assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).fileName()) .isEqualTo("declaration.pdf"); - assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + assertThat(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).content()) .isEqualTo("document".getBytes()); assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "52", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); verify(storage).read(value.getObjectKey()); } + + @Test void compensationDeleteRequiresExactOwnershipAndLogicallyVoidsBeforeStorageCleanup() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(91L); value.setTenantId(0L); value.setBusinessType("CONTAINER_DELIVERY_ORDER"); + value.setBusinessId("41"); value.setStatus("ACTIVE"); value.setDeleted(false); value.setVersion(0); + value.setObjectKey("business-files/0/41/do.pdf"); + when(files.selectOne(any())).thenReturn(value); + when(files.voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L)).thenReturn(1); + when(storage.delete(value.getObjectKey())).thenReturn(true); + + service.delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); + + verify(files).voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L); + verify(storage).delete(value.getObjectKey()); + } + + @Test void compensationMapperUsesExactCompositeOwnershipAndOptimisticVersion() throws Exception { + String sql = String.join(" ", FileObjectMapper.class.getMethod("voidOwned", long.class, + String.class, String.class, long.class, int.class, long.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()); + assertThat(sql) + .contains("id=#{fileId}", "tenant_id=#{tenantId}", "business_type=#{businessType}") + .contains("business_id=#{businessId}", "status='ACTIVE'", "deleted=0", "version=#{version}") + .contains("status='VOID'", "deleted=1", "version=version+1"); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java index e025b7d3b..307360ec6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.client; import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -29,6 +30,12 @@ public interface LoadingDocumentFileClient { ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId); + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse delete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @PathVariable long fileId); + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java index 8f12886f8..68a968aa4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsCaseMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface CustomsCaseMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " + + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") + int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index 6bf93297d..3d8a35e42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,11 +81,8 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } - public CustomsCaseEntity clearedContainerCase(long containerBookingId) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) - .eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED") - .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + public boolean loadingDeclarationCompleted(long containerBookingId) { + return cases.completedLoadingDeclaration(containerBookingId) == 1; } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index ed2c87fc9..61a048f5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; @@ -18,12 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId()); - if (value == null) return gate(false, "MISSING"); - return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); + boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); + return gate(completed, completed ? "COMPLETED" : "MISSING"); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 86fad576e..e363c9a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -106,9 +106,13 @@ public class LoadingListRepository { String fromStatus, String toStatus, String beforeSnapshot, String afterSnapshot, long operatorId, String operatorName, String operationReason) { - mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, operationReason)); + if (changed != 1) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试"); + } } private Map> customerMap(List rows) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index fc96d5cf4..4d6d63f49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -31,12 +31,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.http.MediaType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -49,6 +52,7 @@ import java.util.Set; @Service public class LoadingListService { + private static final Logger log = LoggerFactory.getLogger(LoadingListService.class); private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION"; @@ -317,8 +321,16 @@ public class LoadingListService { requireReplaceableDocument(current, expectedVersion); LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile( businessType, businessId, operatorId, file, fileHash); - return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, - operationId, "UPLOAD_" + documentType, requestHash, operationReason); + try { + return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, + operationId, "UPLOAD_" + documentType, requestHash, operationReason); + } catch (RuntimeException exception) { + compensateUploadedFile(businessType, businessId, metadata.fileId(), operatorId); + if (exception instanceof DataIntegrityViolationException) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); + } + throw exception; + } } private BusinessDocument persistDocument(AggregateRow plan, String documentType, @@ -337,12 +349,18 @@ public class LoadingListService { if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); } catch (DuplicateKeyException exception) { throw conflict("资料已被其他操作上传,请刷新后重试"); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, actionType, - plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), - json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(operationReason)); + try { + repository.appendHistory(plan.getPlanId(), operationId, actionType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(operationReason)); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料历史约束已变化,请刷新后重试"); + } return document(saved); } @@ -414,6 +432,19 @@ public class LoadingListService { } } + private void compensateUploadedFile(String businessType, String businessId, long fileId, long operatorId) { + try { + var response = documentFiles.delete(internalToken, operatorId, 0, businessType, businessId, fileId); + if (response == null || !response.successful()) { + log.error("Business file compensation failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId); + } + } catch (RuntimeException exception) { + log.error("Business file compensation request failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId, exception); + } + } + private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) { if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试"); if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) { diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 552d13600..1ee186d2b 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -70,7 +70,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -84,7 +84,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index ab4efc31c..5033d5924 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1122,7 +1122,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1138,7 +1138,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ 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 aeeeb7b18..364c0b142 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,7 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate - description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed. + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index be11e6816..dc499e353 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,19 +22,37 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCanonicalClearanceCompletedCase() { - CustomsCaseEntity value = new CustomsCaseEntity(); - assertThat(contributor.assess(context()).passed()).isFalse(); - value.setStatus("CLEARANCE_COMPLETED"); - when(repository.clearedContainerCase(81L)).thenReturn(value); - assertThat(contributor.assess(context())) - .satisfies(item -> { - assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); - }); - verify(repository, times(2)).clearedContainerCase(81L); + @Test void uploadedButIncompleteDeclarationRemainsBlocked() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isFalse(); + assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + } + + @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + verify(repository).loadingDeclarationCompleted(81L); verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); } + @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { + String sql = String.join(" ", CustomsCaseMapper.class + .getMethod("completedLoadingDeclaration", long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertThat(sql) + .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") + .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") + .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); + } + private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } } 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 024cefac7..0480e784e 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 @@ -51,7 +51,8 @@ class OutboundDispatchSqlContractTest { .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") .contains("Loading evidence is evaluated once for the physical container/loading plan") - .contains("Customs clearance is also resolved once at container scope") + .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") + .contains("fl_customs_case is not an alternative gate fact") .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 11e77ed09..d25d76a32 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest { .contains("operator_id"); } + @Test + void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception { + String documentInsert = sql("insertDocument", Insert.class); + String historyInsert = sql("insertHistory", Insert.class); + assertThat(documentInsert) + .contains("INSERT INTO fl_container_document") + .contains("#{documentType}", "#{fileId}", "#{fileHash}") + .contains("CURRENT_TIMESTAMP(3)", "version") + .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(historyInsert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("#{planId}", "#{operationId}", "#{actionType}") + .contains("#{beforeSnapshot}", "#{afterSnapshot}"); + } + private static String sql(String methodName, Class annotationType) throws Exception { Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 27c6c17a3..e5c1ddc08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest { .contains("CREATE TRIGGER trg_container_booking_before_update") .contains("current_plan_status IN ('LOADING','COMPLETED')") .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); } private static void assertSimpleCustomsDocumentContract(String sql) { @@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest { .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 8d2e33874..de1ef024c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -597,6 +599,129 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false); + saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-first", 0, null, file); + + assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version()) + .containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); + ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); + saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); + var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, + "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); + when(repository.replaceDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, + 9L, "operator", "pu-update", 2, null, file); + + assertThat(result.fileId()).isEqualTo(102L); + assertThat(result.version()).isEqualTo(3); + verify(repository).replaceDocument(any()); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false)); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false)); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-replay", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenThrow( + new org.springframework.dao.DataIntegrityViolationException("trigger rejected")); + when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(null)); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L)); + } + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 828e0e050..6a028f73e 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 - 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 @@ -49,7 +49,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 - 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 -- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 ## 数据库表索引与注释约束 @@ -77,6 +77,7 @@ - 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3bdb1f82d..3f8c3da74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -44,8 +44,9 @@ public class InboundPlanController { @PostMapping("/convert") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 将入仓单转换为入仓计划。 */ - @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + /** 由平台人工将待分配尾程订单转换为入仓计划。 */ + @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5dd754553..ec121e6d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -40,6 +41,7 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; @Resource private InboundPlanQueryMapper queryMapper; + @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -108,6 +110,8 @@ public class InboundPlanService { if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } + inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), + plan.getPlanNo(), now); orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..9fddae72c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -5,7 +5,9 @@ import com.baiyue.lastleg.inventory.model.InventorySummary; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; public interface InventoryBalanceMapper extends BaseMapper { @@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper { @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode, + @Param("planNo") String planNo, + @Param("updatedAt") LocalDateTime updatedAt); + @Select("